@cosmicdrift/kumiko-framework 0.363.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 (189) 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 +7 -0
  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/routes.js +46 -17
  80. package/dist/api/server.d.ts +2 -1
  81. package/dist/api/server.js +18 -6
  82. package/dist/api/session-cookies.d.ts +1 -0
  83. package/dist/api/session-cookies.js +20 -5
  84. package/dist/api/sse-route.js +16 -1
  85. package/dist/bun-db/index.d.ts +1 -1
  86. package/dist/bun-db/index.js +1 -1
  87. package/dist/bun-db/query.d.ts +8 -3
  88. package/dist/bun-db/query.js +27 -7
  89. package/dist/db/collect-table-metas.js +11 -7
  90. package/dist/db/eagerload.js +2 -2
  91. package/dist/db/event-store-executor-context.d.ts +5 -6
  92. package/dist/db/event-store-executor-context.js +24 -22
  93. package/dist/db/event-store-executor-read.js +31 -16
  94. package/dist/db/event-store-executor-write.js +18 -20
  95. package/dist/db/index.d.ts +1 -1
  96. package/dist/db/index.js +1 -1
  97. package/dist/db/lock-guard.d.ts +4 -0
  98. package/dist/db/lock-guard.js +14 -0
  99. package/dist/db/migrate-generator.js +1 -1
  100. package/dist/db/migrate-runner.js +9 -8
  101. package/dist/db/queries/event-consumer.d.ts +6 -6
  102. package/dist/db/queries/event-store.d.ts +1 -1
  103. package/dist/db/queries/projection-rebuild.d.ts +1 -1
  104. package/dist/db/tenant-db-runner.d.ts +4 -0
  105. package/dist/db/tenant-db-runner.js +17 -0
  106. package/dist/db/tenant-db.d.ts +2 -0
  107. package/dist/db/tenant-db.js +30 -4
  108. package/dist/derivatives/derivatives-context.js +1 -1
  109. package/dist/engine/boot-validator/access-declarations.js +2 -2
  110. package/dist/engine/boot-validator/api-ext.js +3 -3
  111. package/dist/engine/boot-validator/boot-check.js +1 -1
  112. package/dist/engine/boot-validator/config-deps.js +13 -13
  113. package/dist/engine/boot-validator/detail-screens.js +1 -1
  114. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  115. package/dist/engine/boot-validator/entity-handler.js +84 -35
  116. package/dist/engine/boot-validator/entity-list-screens.js +2 -2
  117. package/dist/engine/boot-validator/handler-field-references.js +19 -9
  118. package/dist/engine/boot-validator/i18n-keys.js +2 -2
  119. package/dist/engine/boot-validator/nav.js +1 -1
  120. package/dist/engine/boot-validator/pii-retention.js +8 -8
  121. package/dist/engine/boot-validator/projection-list-screens.js +5 -5
  122. package/dist/engine/boot-validator/query-output-columns.js +7 -7
  123. package/dist/engine/boot-validator/screens.d.ts +2 -1
  124. package/dist/engine/boot-validator/screens.js +153 -95
  125. package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
  126. package/dist/engine/define-handler.d.ts +2 -2
  127. package/dist/engine/define-handler.js +8 -1
  128. package/dist/engine/feature-ast/extractors/handlers.js +2 -0
  129. package/dist/engine/feature-ast/pattern-change-schema.d.ts +2 -0
  130. package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
  131. package/dist/engine/feature-ast/render.js +10 -2
  132. package/dist/engine/feature-entity-handlers.d.ts +2 -2
  133. package/dist/engine/feature-entity-handlers.js +12 -3
  134. package/dist/engine/handler-field-references.d.ts +21 -2
  135. package/dist/engine/handler-field-references.js +55 -17
  136. package/dist/engine/index.d.ts +2 -2
  137. package/dist/engine/schema-builder.js +2 -1
  138. package/dist/engine/soft-delete-cleanup.js +10 -3
  139. package/dist/engine/steps/retry.js +2 -1
  140. package/dist/engine/steps/unsafe-projection-delete.js +2 -2
  141. package/dist/engine/steps/unsafe-projection-upsert.js +2 -2
  142. package/dist/engine/template-variables.d.ts +3 -0
  143. package/dist/engine/template-variables.js +3 -0
  144. package/dist/engine/types/index.d.ts +1 -1
  145. package/dist/env/_zod-introspect.d.ts +1 -0
  146. package/dist/errors/classes.js +1 -0
  147. package/dist/errors/kumiko-error.d.ts +2 -0
  148. package/dist/errors/kumiko-error.js +2 -0
  149. package/dist/errors/write-error-info.js +2 -0
  150. package/dist/event-store/tenant-db-append.js +2 -2
  151. package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
  152. package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
  153. package/dist/files/bind-pending-file-refs.d.ts +2 -0
  154. package/dist/files/bind-pending-file-refs.js +3 -3
  155. package/dist/files/bind-referenced-files.write.d.ts +1 -1
  156. package/dist/files/bind-referenced-files.write.js +22 -13
  157. package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
  158. package/dist/files/create-file-ref-from-bytes.js +67 -4
  159. package/dist/files/create-file-ref.d.ts +22 -0
  160. package/dist/files/create-file-ref.js +53 -25
  161. package/dist/files/file-handle.d.ts +1 -1
  162. package/dist/files/file-handle.js +8 -0
  163. package/dist/files/file-routes.js +1 -1
  164. package/dist/files/index.d.ts +2 -2
  165. package/dist/files/index.js +1 -1
  166. package/dist/files/load-save-file-refs.d.ts +10 -0
  167. package/dist/files/load-save-file-refs.js +47 -0
  168. package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
  169. package/dist/files/purge-expired-file-ref-bytes.js +45 -19
  170. package/dist/jobs/index.d.ts +1 -1
  171. package/dist/jobs/job-runner.d.ts +4 -1
  172. package/dist/jobs/job-runner.js +8 -4
  173. package/dist/observability/escape-hatch-report.d.ts +1 -1
  174. package/dist/observability/escape-hatch-report.js +17 -5
  175. package/dist/pipeline/cascade-handler.js +5 -2
  176. package/dist/pipeline/dispatch-batch.js +79 -31
  177. package/dist/pipeline/dispatch-shared.d.ts +7 -2
  178. package/dist/pipeline/dispatch-shared.js +99 -22
  179. package/dist/pipeline/dispatch-write.js +34 -22
  180. package/dist/pipeline/index.d.ts +1 -0
  181. package/dist/pipeline/index.js +1 -0
  182. package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
  183. package/dist/pipeline/pre-transaction-reservations.js +28 -9
  184. package/dist/rate-limit/bucket.d.ts +3 -3
  185. package/dist/rate-limit/bucket.js +14 -4
  186. package/dist/redis/cache-sync-bus.js +9 -3
  187. package/dist/search/meilisearch-adapter.js +36 -9
  188. package/package.json +7 -5
  189. package/src/changes.json +207 -8
@@ -0,0 +1,317 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/cross-feature-events. Do not edit. -->
2
+
3
+ # cross-feature-events
4
+
5
+ Feature appends a typed domain event via ctx.appendEvent; a r.multiStreamProjection reacts asynchronously.
6
+
7
+ ## README
8
+
9
+ # Cross-Feature Reactions
10
+
11
+ A handler emits a typed domain event onto the aggregate stream, and an
12
+ async MultiStreamProjection reacts. Since Sprint E this is the
13
+ Marten-gold-standard path for cross-feature reactions — the old
14
+ `ctx.emit` + `r.postEvent` branch was removed in E.2.
15
+
16
+ ## What the sample shows
17
+
18
+ Three building blocks:
19
+
20
+ - **`r.defineEvent("name", zodSchema)`** declares the name and payload.
21
+ The qualified name (e.g. `pubsub-orders:event:order-placed`) comes
22
+ back as `.name` — pass it directly to `ctx.appendEvent`. Events that
23
+ aren't registered are rejected by `ctx.appendEvent` at the emit site.
24
+
25
+ - **`ctx.appendEvent({ aggregateId, aggregateType, type, payload })`**
26
+ appends the event onto the aggregate stream within the same TX.
27
+ Version lineage is continued automatically — no synthetic
28
+ `"pubsub"` stream anymore, the event truly lives on the aggregate.
29
+
30
+ - **`r.multiStreamProjection({ name, apply })`** declares a consumer.
31
+ The event dispatcher walks the `events` table via cursor and
32
+ delivers **at-least-once** in event-ID order. `table` can be omitted
33
+ — the MSP then becomes a pure side effect (send mail, webhook,
34
+ external system). With `table`, the apply materializes a persistent
35
+ read model.
36
+
37
+ ## Flow
38
+
39
+ ```
40
+ HTTP → writeHandler
41
+ ↓ TX begin
42
+ orderExecutor.create(...) ← business row + auto-event (pubsubOrder.created, v1)
43
+ ctx.appendEvent({...}) ← domain event (pubsub-orders:event:order-placed, v2)
44
+ ↓ TX commit
45
+ (async, post-commit)
46
+ event-dispatcher runOnce
47
+ ↓ per-consumer cursor advance
48
+ MSP apply → capture side effect
49
+ ```
50
+
51
+ ## Migration note
52
+
53
+ `r.postEvent` was removed in Sprint E.2. `ctx.appendEvent` +
54
+ `r.multiStreamProjection` is the unified Marten API for CRUD events,
55
+ domain events, and cross-feature reactions.
56
+
57
+ ## Test
58
+
59
+ The integration test pins three guarantees:
60
+
61
+ 1. **TX atomicity** — after commit the event row sits in the `events`
62
+ table on the aggregate stream (not on a synthetic `"pubsub"`
63
+ stream).
64
+ 2. **MSP delivery** — `runOnce()` delivers the event to the handler
65
+ with the correct `tenantId` + payload.
66
+ 3. **Rollback safety** — when the write fails, neither the business
67
+ row nor the event row nor the MSP side effect exists.
68
+
69
+ ```bash
70
+ bun kumiko test integration samples/cross-feature-events
71
+ ```
72
+
73
+ ## Source files
74
+
75
+ ### src/__tests__/feature.integration.test.ts
76
+
77
+ ```ts
78
+ // Cross-Feature Reactions Sample — Integration Test
79
+ //
80
+ // Proves:
81
+ // 1. ctx.appendEvent persists the domain event into the events-table
82
+ // inside the same TX as the business write, on the aggregate's own
83
+ // stream (aggregateType = "pubsub-order", not the legacy "pubsub").
84
+ // 2. The event-dispatcher picks it up and invokes the
85
+ // r.multiStreamProjection handler at-least-once, with event.tenantId
86
+ // + payload intact.
87
+ // 3. The handler is a regular async function — no timers, no magic
88
+ // callbacks. runOnce() drains deterministically.
89
+
90
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
91
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
92
+ import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
93
+ import {
94
+ createTestUser,
95
+ resetEventStore,
96
+ setupTestStack,
97
+ type TestStack,
98
+ unsafeCreateEntityTable,
99
+ } from "@cosmicdrift/kumiko-framework/stack";
100
+ import { capturedEvents, orderEntity, pubsubOrderFeature } from "../feature";
101
+
102
+ let stack: TestStack;
103
+
104
+ const customer = createTestUser({ roles: ["Customer"] });
105
+
106
+ beforeAll(async () => {
107
+ stack = await setupTestStack({ features: [pubsubOrderFeature] });
108
+ await unsafeCreateEntityTable(stack.db, orderEntity);
109
+ });
110
+
111
+ afterAll(async () => {
112
+ await stack.cleanup();
113
+ });
114
+
115
+ beforeEach(async () => {
116
+ capturedEvents.length = 0;
117
+ stack.events.reset();
118
+ // Reset events + consumer cursors so each test starts from a clean log.
119
+ await resetEventStore(stack, ["read_sample_pubsub_orders"]);
120
+ });
121
+
122
+ describe("cross-feature reactions via ctx.appendEvent + r.multiStreamProjection", () => {
123
+ test("write commits → event on aggregate stream → MSP handler receives it", async () => {
124
+ const data = await stack.http.writeOk(
125
+ "pubsub-orders:write:order:place",
126
+ { customer: "Alice", product: "Widget" },
127
+ customer,
128
+ );
129
+ const orderId = String(data["id"]);
130
+
131
+ // The event row is committed alongside the order row. It lives on the
132
+ // order's OWN stream now — aggregateType "pubsub-order", version 2
133
+ // (auto "created" is v1, domain "order-placed" is v2).
134
+ const domainEvents = await selectMany(stack.db, eventsTable, {
135
+ aggregateType: "pubsub-order",
136
+ type: "pubsub-orders:event:order-placed",
137
+ });
138
+ expect(domainEvents).toHaveLength(1);
139
+ expect(domainEvents[0]?.aggregateId).toBe(orderId);
140
+ expect(domainEvents[0]?.version).toBeGreaterThan(1);
141
+ expect(domainEvents[0]?.payload).toMatchObject({
142
+ id: orderId,
143
+ customer: "Alice",
144
+ product: "Widget",
145
+ });
146
+ expect(domainEvents[0]?.tenantId).toBe(customer.tenantId);
147
+
148
+ // The dispatcher is built but not started in tests. runOnce drains
149
+ // deterministically — no timer-induced flakiness.
150
+ await stack.eventDispatcher?.runOnce();
151
+
152
+ expect(capturedEvents).toHaveLength(1);
153
+ expect(capturedEvents[0]).toMatchObject({
154
+ type: "pubsub-orders:event:order-placed",
155
+ payload: { id: orderId, customer: "Alice", product: "Widget" },
156
+ tenantId: customer.tenantId,
157
+ });
158
+ });
159
+
160
+ test("MSP is not invoked when the write rolls back (no half-emitted events)", async () => {
161
+ // An invalid write (missing required fields) fails Zod validation
162
+ // before any DB mutation. No event row, no MSP invocation — the
163
+ // appendEvent would have run in the same TX, so the rollback takes
164
+ // it with.
165
+ await stack.http.writeErr("pubsub-orders:write:order:place", { customer: "" }, customer);
166
+
167
+ const domainEvents = await selectMany(stack.db, eventsTable, {
168
+ type: "pubsub-orders:event:order-placed",
169
+ });
170
+ expect(domainEvents).toHaveLength(0);
171
+
172
+ await stack.eventDispatcher?.runOnce();
173
+ expect(capturedEvents).toHaveLength(0);
174
+ });
175
+
176
+ test("multiple appendEvents → events delivered in id-order, at-least-once", async () => {
177
+ for (const name of ["Bob", "Carol", "Dave"]) {
178
+ await stack.http.writeOk(
179
+ "pubsub-orders:write:order:place",
180
+ { customer: name, product: "X" },
181
+ customer,
182
+ );
183
+ }
184
+
185
+ await stack.eventDispatcher?.runOnce();
186
+
187
+ expect(capturedEvents.map((e) => e.payload.customer)).toEqual(["Bob", "Carol", "Dave"]);
188
+ });
189
+ });
190
+ ```
191
+
192
+ ### src/feature.ts
193
+
194
+ ```ts
195
+ // Cross-Feature Reactions Sample (Marten gold-standard path)
196
+ //
197
+ // Demonstrates the Sprint-E pattern for reacting to events across features:
198
+ // a writeHandler emits a DOMAIN event via `ctx.appendEvent(...)` onto the
199
+ // aggregate's own stream, and a `r.multiStreamProjection` picks it up
200
+ // asynchronously through the event-dispatcher.
201
+ //
202
+ // Three moving pieces:
203
+ //
204
+ // 1. `r.defineEvent("short-name", zodSchema)` declares the event shape.
205
+ // Returns a def whose `.name` is the fully-qualified event name —
206
+ // pass that straight to `ctx.appendEvent` so there's no string to
207
+ // hand-type (and no way to emit an unregistered event: appendEvent
208
+ // rejects at the emit site).
209
+ //
210
+ // 2. `ctx.appendEvent({ aggregateId, aggregateType, type, payload })`
211
+ // writes the event onto the aggregate's own stream inside the same
212
+ // transaction as the business write. Commit both or roll back both —
213
+ // no lost events, no orphan events. The event carries real
214
+ // aggregate/version lineage (Marten-aligned).
215
+ //
216
+ // 3. `r.multiStreamProjection({ name, apply })` declares an async
217
+ // consumer. The event-dispatcher walks the events-table via a
218
+ // persistent cursor and invokes the matching apply() handler
219
+ // at-least-once in events.id order.
220
+ //
221
+ // With `table` omitted the MSP is a pure side-effect consumer —
222
+ // send notifications, post webhooks, sync external systems. The tx
223
+ // argument is still live for the apply() if you need it.
224
+
225
+ import {
226
+ createEntity,
227
+ createEntityExecutor,
228
+ createTextField,
229
+ defineFeature,
230
+ emitEvent,
231
+ typedPayload,
232
+ } from "@app/define";
233
+ import { z } from "zod";
234
+
235
+ // --- Entity ---
236
+
237
+ export const orderEntity = createEntity({
238
+ table: "read_sample_pubsub_orders",
239
+ fields: {
240
+ customer: createTextField({ personal: { of: "id" }, find: "none", required: true }),
241
+ product: createTextField({ personal: false, reason: "is_business_data", required: true }),
242
+ },
243
+ });
244
+
245
+ // --- Subscriber capture (for the integration test) ---
246
+ //
247
+ // Real consumers would write to a downstream store or call an external
248
+ // service. Here we capture in-memory so the test can assert the roundtrip.
249
+ // Exported so the test can import + reset between cases.
250
+
251
+ export type CapturedEvent = {
252
+ readonly type: string;
253
+ readonly payload: { readonly id: unknown; readonly customer: string };
254
+ readonly tenantId: string;
255
+ };
256
+ export const capturedEvents: CapturedEvent[] = [];
257
+
258
+ // --- Feature ---
259
+
260
+ export const pubsubOrderFeature = defineFeature("pubsubOrders", (r) => {
261
+ r.entity("pubsub-order", orderEntity);
262
+
263
+ // Define the event shape once. The returned `.name` is the qualified
264
+ // name ("pubsub-orders:event:order-placed") — pass it to ctx.appendEvent.
265
+ const orderPlaced = r.defineEvent(
266
+ "order-placed",
267
+ z.object({ id: z.string(), customer: z.string(), product: z.string() }),
268
+ { piiFields: "none" },
269
+ );
270
+
271
+ const { executor: orderExecutor } = createEntityExecutor("pubsub-order", orderEntity);
272
+
273
+ r.writeHandler(
274
+ "order:place",
275
+ z.object({ customer: z.string().min(1), product: z.string().min(1) }),
276
+ async (event, ctx) => {
277
+ const result = await orderExecutor.create(event.payload, event.user, ctx.db);
278
+ if (result.isSuccess) {
279
+ // emitEvent runs inside the executor's TX and is typed against
280
+ // the orderPlaced event-def — a wrong payload shape is a compile
281
+ // error, not a runtime Zod reject. The event lands on the order's
282
+ // OWN stream (aggregateType "pubsub-order"), with the correct
283
+ // version bumped automatically. Schema validation still runs at
284
+ // append-time; a mismatch rolls back the whole write.
285
+ await emitEvent(ctx, orderPlaced, {
286
+ aggregateId: String(result.data.id),
287
+ aggregateType: "pubsub-order",
288
+ payload: {
289
+ id: String(result.data.id),
290
+ customer: event.payload.customer,
291
+ product: event.payload.product,
292
+ },
293
+ });
294
+ }
295
+ return result;
296
+ },
297
+ { access: { roles: ["Admin", "Customer"] } },
298
+ );
299
+
300
+ // Async consumer — Marten-style. Fires after commit, via the event-
301
+ // dispatcher. No `table` means this MSP is pure side-effect (no state
302
+ // persisted). Delivery is at-least-once and ordered per consumer.
303
+ r.multiStreamProjection({
304
+ name: "record-order-placed",
305
+ apply: {
306
+ [orderPlaced.name]: async (event) => {
307
+ const payload = typedPayload(event, orderPlaced);
308
+ capturedEvents.push({
309
+ type: event.type,
310
+ payload,
311
+ tenantId: event.tenantId,
312
+ });
313
+ },
314
+ },
315
+ });
316
+ });
317
+ ```
@@ -0,0 +1,314 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/crypto-shredding-hr. Do not edit. -->
2
+
3
+ # crypto-shredding-hr
4
+
5
+ Crypto-shredding: PII fields encrypted per subject key; forgetting the subject erases the key and every field it protected.
6
+
7
+ ## README
8
+
9
+ # Crypto-shredding (mini HR)
10
+
11
+ Make GDPR forget a key-erase instead of a data-hunt. Fields annotated as PII
12
+ are encrypted under a per-subject key (DEK); forgetting the subject erases
13
+ that key in the KMS — the ciphertext stays in rows and events but is
14
+ unreadable forever, and reads render the `[[erased]]` sentinel.
15
+
16
+ The recipe ships a mini HR feature: an `employee` entity whose name and email
17
+ are the employee's own PII, and an `hr-comment` entity whose body is
18
+ encrypted under the key of the employee the comment is *about* — so a
19
+ manager's comment dies with the employee's key, not the manager's.
20
+
21
+ ## What it shows
22
+
23
+ - **`pii: true` on a field** — encrypted under the subject key of its own
24
+ row (`user:<row.id>`). At rest the column holds a
25
+ `kumiko-pii:v2:<subjectKey>:<ciphertext>` envelope; the API returns
26
+ plaintext as long as the key exists.
27
+ - **`userOwned: { ownerField }`** — encrypted under the key of the user
28
+ another field points at. Erasing that user's key makes every row about
29
+ them unreadable, with no per-row cleanup hunt.
30
+ - **Plain fields stay queryable** — `department` is not personal data, so it
31
+ remains plaintext, sortable and searchable.
32
+ - **`lookupable: true` on an encrypted field** — the framework maintains an
33
+ HMAC blind-index column (`email_bidx`), so equality lookups (login, dedup
34
+ checks) keep working on ciphertext: the query compiler rewrites
35
+ `email = $1` to `(email = $1 OR email_bidx = hmac($1))`. Needs
36
+ `runProdApp({ blindIndexKey })` (a dedicated 32-byte key, NOT the KEK).
37
+ Sorting stays impossible by design — the boot validator rejects
38
+ `sortable` on subject-annotated fields, because sorting reads the
39
+ projection column and that stays ciphertext.
40
+ - **`searchable: true` on an encrypted field** (#1610) — substring search
41
+ works: the search consumer decrypts into a derived Meilisearch index, while
42
+ events and projection keep the ciphertext. `search/purge-subject.ts` drops
43
+ those documents when the subject key is erased, so the derived index does
44
+ not outlive the forget. `sensitive: true` + `searchable` still throws —
45
+ those values may never be read back at all.
46
+
47
+ Encrypting a field is not a reason to make it unfindable. If a list needs
48
+ ordering over an encrypted name, the way out is a search-driven list rather
49
+ than an alphabetically paginated one — not a plaintext sort column beside
50
+ the encrypted one, which would survive the key erase and quietly undo the
51
+ shredding.
52
+ - **Forget = `kms.eraseKey(subject)`** — afterwards detail *and* list render
53
+ `[[erased]]` for every protected field while the stored ciphertext bytes
54
+ stay untouched.
55
+
56
+ ## Feature composition
57
+
58
+ ```
59
+ hr → employee (displayName/email pii) + hr-comment (body userOwned)
60
+ ```
61
+
62
+ Requires a KMS adapter: `runProdApp({ kms: createPgKmsAdapter(...) })` in
63
+ production, `configurePiiSubjectKms(new InMemoryKmsAdapter())` in tests.
64
+ Without one, fields are stored in plaintext and a boot warning is logged.
65
+ The `crypto-shredding` bundled feature ships the operator `forget-subject`
66
+ command; `user-data-rights` erases user keys automatically after the
67
+ deletion grace period.
68
+
69
+ ## Flow
70
+
71
+ 1. Create an employee → the executor creates a subject key on first insert
72
+ and stores `displayName`/`email` as ciphertext.
73
+ 2. A comment about the employee is encrypted under the *employee's* key via
74
+ `userOwned: { ownerField: "employeeId" }`.
75
+ 3. `kms.eraseKey({ kind: "user", userId })` — idempotent, tombstone stays
76
+ for the audit trail.
77
+ 4. Detail responses (list, too, for `employee`) now show `[[erased]]` for every field the key
78
+ protected; events and rows keep their original (unreadable) bytes.
79
+ 5. Lookups by email stop matching after the forget: the pipeline nulls the
80
+ blind index immediately (`nullBlindIndexesForSubject`), and every
81
+ projection rebuild recomputes it from the (now undecryptable) ciphertext
82
+ to `NULL`.
83
+
84
+ ## Source files
85
+
86
+ ### src/__tests__/feature.integration.test.ts
87
+
88
+ ```ts
89
+ // Crypto-Shredding Sample — Integration Test
90
+ // Proves: PII ciphertext at rest + plaintext over the API, userOwned
91
+ // cross-row encryption, and forget-as-key-erase ([[erased]] everywhere the
92
+ // subject's key was used, events untouched).
93
+
94
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
95
+ import { asRawClient, fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
96
+ import {
97
+ configureBlindIndexKey,
98
+ configurePiiSubjectKms,
99
+ InMemoryKmsAdapter,
100
+ PII_CIPHERTEXT_PREFIX,
101
+ PII_ERASED_SENTINEL,
102
+ subjectIdToKey,
103
+ } from "@cosmicdrift/kumiko-framework/crypto";
104
+ import { buildEntityTable, nullBlindIndexesForSubject } from "@cosmicdrift/kumiko-framework/db";
105
+ import type { SaveContext } from "@cosmicdrift/kumiko-framework/engine";
106
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
107
+ import {
108
+ setupTestStack,
109
+ type TestStack,
110
+ TestUsers,
111
+ unsafeCreateEntityTable,
112
+ } from "@cosmicdrift/kumiko-framework/stack";
113
+ import {
114
+ resetBlindIndexKeyForTests,
115
+ resetPiiSubjectKmsForTests,
116
+ resetTestTables,
117
+ } from "@cosmicdrift/kumiko-framework/testing";
118
+ import { employeeEntity, hrCommentEntity, hrFeature } from "../feature";
119
+
120
+ let stack: TestStack;
121
+ let kms: InMemoryKmsAdapter;
122
+
123
+ const admin = TestUsers.admin;
124
+
125
+ beforeAll(async () => {
126
+ stack = await setupTestStack({ features: [hrFeature] });
127
+ await unsafeCreateEntityTable(stack.db, employeeEntity);
128
+ await unsafeCreateEntityTable(stack.db, hrCommentEntity);
129
+ await createEventsTable(stack.db);
130
+ });
131
+
132
+ afterAll(async () => {
133
+ await stack.cleanup();
134
+ resetPiiSubjectKmsForTests();
135
+ resetBlindIndexKeyForTests();
136
+ });
137
+
138
+ beforeEach(async () => {
139
+ // Fresh adapter per test = fresh key universe. Rows from earlier tests
140
+ // would name subjects this adapter never minted (fail-loud KeyNotFound),
141
+ // so the tables reset alongside the KMS.
142
+ await resetTestTables(stack.db, ["read_hr_employees", "read_hr_comments"]);
143
+ kms = new InMemoryKmsAdapter();
144
+ configurePiiSubjectKms(kms);
145
+ // Blind-index key: what runProdApp({ blindIndexKey }) wires in production.
146
+ configureBlindIndexKey(Buffer.alloc(32, 7).toString("base64"));
147
+ });
148
+
149
+ async function rawEmployeeRow(id: string): Promise<{ display_name: string; email: string }> {
150
+ const result = await asRawClient(stack.db).unsafe(
151
+ "SELECT display_name, email FROM read_hr_employees WHERE id = $1",
152
+ [id],
153
+ );
154
+ // biome-ignore lint/suspicious/noExplicitAny: raw sql result shape
155
+ const rows = ((result as any).rows ?? result) as Array<{ display_name: string; email: string }>;
156
+ const row = rows[0];
157
+ if (!row) throw new Error(`no employee row ${id}`);
158
+ return row;
159
+ }
160
+
161
+ async function createEmployee(): Promise<string> {
162
+ const created = await stack.http.writeOk<SaveContext>(
163
+ "hr:write:employee:create",
164
+ { displayName: "Grace Hopper", email: "grace@example.com", department: "Engineering" },
165
+ admin,
166
+ );
167
+ return String(created.id);
168
+ }
169
+
170
+ describe("crypto-shredding-hr", () => {
171
+ test("PII is ciphertext at rest, plaintext over the API", async () => {
172
+ const employeeId = await createEmployee();
173
+
174
+ const raw = await rawEmployeeRow(employeeId);
175
+ expect(raw.display_name).toStartWith(`${PII_CIPHERTEXT_PREFIX}user:${employeeId}:`);
176
+ expect(raw.email).toStartWith(`${PII_CIPHERTEXT_PREFIX}user:${employeeId}:`);
177
+
178
+ const detail = await stack.http.queryOk<Record<string, unknown>>(
179
+ "hr:query:employee:detail",
180
+ { id: employeeId },
181
+ admin,
182
+ );
183
+ expect(detail["displayName"]).toBe("Grace Hopper");
184
+ expect(detail["email"]).toBe("grace@example.com");
185
+ // Non-PII field stays plaintext at rest and readable.
186
+ expect(detail["department"]).toBe("Engineering");
187
+ });
188
+
189
+ test("userOwned comment is encrypted under the employee's key", async () => {
190
+ const employeeId = await createEmployee();
191
+
192
+ const comment = await stack.http.writeOk<SaveContext>(
193
+ "hr:write:hr-comment:create",
194
+ { employeeId, body: "was on sick leave in March", authorName: "Manager X" },
195
+ admin,
196
+ );
197
+
198
+ const result = await asRawClient(stack.db).unsafe(
199
+ "SELECT body FROM read_hr_comments WHERE id = $1",
200
+ [String(comment.id)],
201
+ );
202
+ // biome-ignore lint/suspicious/noExplicitAny: raw sql result shape
203
+ const rows = ((result as any).rows ?? result) as Array<{ body: string }>;
204
+ // The comment row's ciphertext names the EMPLOYEE as subject, not the row.
205
+ expect(rows[0]?.body).toStartWith(`${PII_CIPHERTEXT_PREFIX}user:${employeeId}:`);
206
+ });
207
+
208
+ test("forget: eraseKey renders [[erased]] for the employee AND comments about them", async () => {
209
+ const employeeId = await createEmployee();
210
+ await stack.http.writeOk<SaveContext>(
211
+ "hr:write:hr-comment:create",
212
+ { employeeId, body: "was on sick leave in March", authorName: "Manager X" },
213
+ admin,
214
+ );
215
+
216
+ // What user-data-rights does after the grace period — or a DPO via the
217
+ // crypto-shredding feature's forget-subject command.
218
+ await kms.eraseKey({ kind: "user", userId: employeeId });
219
+
220
+ const detail = await stack.http.queryOk<Record<string, unknown>>(
221
+ "hr:query:employee:detail",
222
+ { id: employeeId },
223
+ admin,
224
+ );
225
+ expect(detail["displayName"]).toBe(PII_ERASED_SENTINEL);
226
+ expect(detail["email"]).toBe(PII_ERASED_SENTINEL);
227
+ expect(detail["department"]).toBe("Engineering");
228
+
229
+ const list = await stack.http.queryOk<{ rows: Array<Record<string, unknown>> }>(
230
+ "hr:query:employee:list",
231
+ {},
232
+ admin,
233
+ );
234
+ const listed = list.rows.find((i) => String(i["id"]) === employeeId);
235
+ expect(listed?.["displayName"]).toBe(PII_ERASED_SENTINEL);
236
+
237
+ // The ciphertext itself is untouched — rows and events keep their bytes,
238
+ // they just can never be decrypted again.
239
+ const raw = await rawEmployeeRow(employeeId);
240
+ expect(raw.email).toStartWith(`${PII_CIPHERTEXT_PREFIX}user:${employeeId}:`);
241
+ });
242
+ });
243
+
244
+ describe("crypto-shredding-hr — blind-index lookups (#818)", () => {
245
+ const employeeTable = buildEntityTable("employee", employeeEntity);
246
+
247
+ test("equality lookup by email works on the encrypted column — until the key is erased", async () => {
248
+ const employeeId = await createEmployee();
249
+
250
+ // The email column holds ciphertext, yet an equality lookup — what a
251
+ // login or dedup check does — still finds the row: the query compiler
252
+ // rewrites `email = $1` to `(email = $1 OR email_bidx = hmac($1))`.
253
+ const hit = await fetchOne<Record<string, unknown>>(stack.db, employeeTable, {
254
+ email: "grace@example.com",
255
+ });
256
+ expect(String(hit?.["id"])).toBe(employeeId);
257
+
258
+ // Forget: erase the key AND null the blind index (user-data-rights does
259
+ // both automatically after the grace period).
260
+ await kms.eraseKey({ kind: "user", userId: employeeId });
261
+ await nullBlindIndexesForSubject(
262
+ stack.db,
263
+ new Map([["hr", hrFeature]]),
264
+ subjectIdToKey({ kind: "user", userId: employeeId }),
265
+ );
266
+
267
+ // The lookup value is gone with the key — no row matches anymore.
268
+ expect(await fetchOne(stack.db, employeeTable, { email: "grace@example.com" })).toBeUndefined();
269
+ });
270
+ });
271
+ ```
272
+
273
+ ### src/feature.ts
274
+
275
+ ```ts
276
+ import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
277
+
278
+ export const employeeEntity = createEntity({
279
+ table: "read_hr_employees",
280
+ fields: {
281
+ displayName: createTextField({ required: true, personal: "self", find: "none" }),
282
+ email: createTextField({ required: true, format: "email", personal: "self", find: "exact" }),
283
+ department: createTextField({ personal: false, reason: "is_business_data", sortable: true }),
284
+ },
285
+ softDelete: true,
286
+ });
287
+
288
+ export const hrCommentEntity = createEntity({
289
+ table: "read_hr_comments",
290
+ fields: {
291
+ employeeId: createTextField({ personal: "ref", required: true }),
292
+ body: createTextField({ required: true, personal: { of: "employeeId" }, find: "none" }),
293
+ authorName: createTextField({ personal: { of: "id" }, find: "none" }),
294
+ },
295
+ softDelete: true,
296
+ });
297
+
298
+ const hrWrite = { access: { roles: ["Admin"] } } as const;
299
+ const hrRead = { access: { roles: ["Admin"] } } as const;
300
+
301
+ export const hrFeature = defineFeature("hr", (r) => {
302
+ r.crud("employee", employeeEntity, {
303
+ write: hrWrite,
304
+ read: hrRead,
305
+ verbs: { update: false, delete: false, restore: false },
306
+ });
307
+
308
+ r.crud("hr-comment", hrCommentEntity, {
309
+ write: hrWrite,
310
+ read: hrRead,
311
+ verbs: { update: false, delete: false, restore: false, list: false },
312
+ });
313
+ });
314
+ ```