@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.
- 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 +7 -0
- 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/routes.js +46 -17
- 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 +8 -3
- package/dist/bun-db/query.js +27 -7
- package/dist/db/collect-table-metas.js +11 -7
- package/dist/db/eagerload.js +2 -2
- package/dist/db/event-store-executor-context.d.ts +5 -6
- package/dist/db/event-store-executor-context.js +24 -22
- package/dist/db/event-store-executor-read.js +31 -16
- 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 +1 -1
- package/dist/db/migrate-runner.js +9 -8
- package/dist/db/queries/event-consumer.d.ts +6 -6
- package/dist/db/queries/event-store.d.ts +1 -1
- package/dist/db/queries/projection-rebuild.d.ts +1 -1
- 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 +2 -0
- package/dist/db/tenant-db.js +30 -4
- package/dist/derivatives/derivatives-context.js +1 -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 +2 -2
- 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 +8 -8
- 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 +153 -95
- package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
- package/dist/engine/define-handler.d.ts +2 -2
- package/dist/engine/define-handler.js +8 -1
- package/dist/engine/feature-ast/extractors/handlers.js +2 -0
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +2 -0
- package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
- package/dist/engine/feature-ast/render.js +10 -2
- package/dist/engine/feature-entity-handlers.d.ts +2 -2
- package/dist/engine/feature-entity-handlers.js +12 -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 +2 -2
- package/dist/engine/schema-builder.js +2 -1
- package/dist/engine/soft-delete-cleanup.js +10 -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 +2 -2
- package/dist/engine/template-variables.d.ts +3 -0
- package/dist/engine/template-variables.js +3 -0
- package/dist/engine/types/index.d.ts +1 -1
- 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/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 +1 -1
- 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/purge-expired-file-ref-bytes.d.ts +4 -1
- package/dist/files/purge-expired-file-ref-bytes.js +45 -19
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +4 -1
- package/dist/jobs/job-runner.js +8 -4
- 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-shared.d.ts +7 -2
- package/dist/pipeline/dispatch-shared.js +99 -22
- package/dist/pipeline/dispatch-write.js +34 -22
- package/dist/pipeline/index.d.ts +1 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +28 -9
- 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 +36 -9
- package/package.json +7 -5
- 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
|
+
```
|