From 8a8bf370c7d5e1657b1ae117ead5eb587a991b03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Be=C3=B1at=20Espi=C3=B1a?= Date: Sun, 25 Oct 2026 01:07:57 +0200 Subject: [PATCH 1/2] feat(core): personal data in events, forgotten by crypto-shredding Events are never deleted, so a person's right to erasure needs another answer than deleting rows. An event's payload now takes personal and person next to z: a field marked personal is stored encrypted with a key of the person the event names, one per person in the adapter's database, and forget(personId) throws that key away and appends PersonalDataForgotten to the person's stream, in one transaction. The field then reads as null everywhere, and the types say so: T | null for whoever reads it, T for whoever writes it, so the compiler finds every place that assumed the data would always be there. Forgetting is an event because what the log held has leaked into read models and the outside world: a read model that projects a personal field must project PersonalDataForgotten too, which the generator checks through a type in .bounda/types.ts, and a policy on it tells the providers. The envelope carries a fingerprint of the key, so a person forgotten and registered again reads their old events as null next to the new ones. The global stream shows the fields as stored. --- .changeset/personal-data.md | 10 + docs/src/content/docs/adapters/cloudflare.md | 5 +- docs/src/content/docs/adapters/index.mdx | 1 + .../forgetting-in-an-immutable-log.md | 149 +++++++++ docs/src/content/docs/guides/personal-data.md | 157 +++++++++ .../content/docs/guides/project-layout.mdx | 4 +- docs/src/content/docs/guides/read-models.md | 4 + .../content/docs/guides/reading-the-stream.md | 6 +- .../src/content/docs/reference/conventions.md | 16 +- .../src/content/docs/reference/limitations.md | 5 + packages/cli/src/generate/discover.test.ts | 73 +++++ packages/cli/src/generate/discover.ts | 47 ++- packages/cli/src/generate/emit/emit.test.ts | 113 +++++++ packages/cli/src/generate/emit/plus-types.ts | 15 +- packages/cli/src/generate/emit/types.ts | 67 +++- packages/cloudflare/src/bounda-object.ts | 16 + packages/cloudflare/src/client.ts | 3 + packages/cloudflare/src/worker.ts | 21 +- packages/cloudflare/test/app.ts | 57 +++- .../cloudflare/test/bounda-object.test.ts | 62 +++- packages/cloudflare/test/client-types.test.ts | 1 + packages/cloudflare/test/client.test.ts | 11 + packages/cloudflare/test/recording-stub.ts | 12 +- .../cloudflare/test/storage-contracts.test.ts | 2 + packages/cloudflare/test/worker.test.ts | 28 +- packages/core/src/adapter/adapter.ts | 3 + packages/core/src/adapter/index.ts | 1 + packages/core/src/adapter/sqlite/adapter.ts | 2 + .../src/adapter/sqlite/personal-key-store.ts | 34 ++ packages/core/src/adapter/sqlite/schema.ts | 6 + .../core/src/adapter/sqlite/sqlite.test.ts | 3 + .../src/adapter/storage/personal-key-store.ts | 12 + packages/core/src/adapter/testing/index.ts | 5 + .../testing/personal-key-store.contract.ts | 53 +++ .../testing/storage-transaction.contract.ts | 30 ++ .../src/kernel/aggregate/build-aggregates.ts | 16 +- packages/core/src/kernel/aggregate/runtime.ts | 5 + packages/core/src/kernel/app.ts | 28 +- .../core/src/kernel/events/app-event-store.ts | 41 +++ .../src/kernel/personal-data/crypto.test.ts | 66 ++++ .../core/src/kernel/personal-data/crypto.ts | 141 ++++++++ .../kernel/personal-data/event-store.test.ts | 309 ++++++++++++++++++ .../src/kernel/personal-data/event-store.ts | 136 ++++++++ .../src/kernel/personal-data/forget.test.ts | 113 +++++++ .../core/src/kernel/personal-data/forget.ts | 83 +++++ .../src/kernel/personal-data/marks.test.ts | 60 ++++ .../core/src/kernel/personal-data/marks.ts | 44 +++ .../personal-data/personal-data.test.ts | 273 ++++++++++++++++ .../core/src/kernel/policy/build-policies.ts | 9 +- .../core/src/kernel/read-model/rebuild.ts | 8 +- .../src/kernel/unit-of-work/unit-of-work.ts | 2 +- packages/core/src/memory/index.ts | 4 + packages/core/src/memory/memory.test.ts | 2 + .../core/src/memory/personal-key-store.ts | 30 ++ packages/core/src/memory/transaction.ts | 65 +++- packages/core/src/modules/bounda-events.ts | 31 ++ packages/core/src/modules/event.ts | 4 +- packages/core/src/modules/index.ts | 18 + packages/core/src/modules/payload.ts | 20 +- packages/core/src/modules/personal.test.ts | 33 ++ packages/core/src/modules/personal.ts | 127 +++++++ packages/core/src/modules/validate.test.ts | 28 ++ packages/core/src/modules/validate.ts | 28 +- .../domain/order/+types/order-cancelled.ts | 2 +- .../app/domain/order/+types/order-paid.ts | 2 +- .../app/domain/order/+types/order-placed.ts | 2 +- .../app/domain/order/+types/reminder-sent.ts | 2 +- .../fixtures/order-app/.bounda/registry.ts | 15 +- .../fixtures/order-app/.bounda/types.ts | 21 ++ .../customer/+types/customer-registered.ts | 2 +- .../domain/customer/customer-registered.ts | 3 +- .../domain/order/+types/order-cancelled.ts | 2 +- .../app/domain/order/+types/order-paid.ts | 2 +- .../app/domain/order/+types/order-placed.ts | 2 +- .../app/domain/order/order-placed.ts | 5 +- .../audit-on-personal-data-forgotten.ts | 10 + .../audit-on-personal-data-forgotten.ts | 5 + .../customer/on-customer-registered.ts | 2 +- .../read/customer-directory/+types/view.ts | 5 + .../bounda/+types/personal-data-forgotten.ts | 10 + .../bounda/personal-data-forgotten.ts | 5 + .../customer/+types/customer-registered.ts | 10 + .../customer/customer-registered.ts | 8 + .../queries/+types/get-customer.ts | 22 ++ .../queries/get-customer.ts | 8 + .../app/read/customer-directory/view.ts | 6 + .../bounda/+types/personal-data-forgotten.ts | 10 + .../bounda/personal-data-forgotten.ts | 5 + packages/core/test-types/inference.test-d.ts | 110 ++++++- packages/core/test-types/negatives.test-d.ts | 48 +++ packages/postgresql/src/index.ts | 5 + packages/postgresql/src/personal-key-store.ts | 34 ++ packages/postgresql/src/postgresql.test.ts | 3 + packages/postgresql/src/schema.ts | 7 + .../src/cloudflare/clients.test.ts | 2 + .../react-router/src/cloudflare/clients.ts | 1 + .../src/cloudflare/test-support.ts | 1 + packages/sqlite/src/sqlite.test.ts | 3 + 98 files changed, 3038 insertions(+), 100 deletions(-) create mode 100644 .changeset/personal-data.md create mode 100644 docs/src/content/docs/concepts/forgetting-in-an-immutable-log.md create mode 100644 docs/src/content/docs/guides/personal-data.md create mode 100644 packages/core/src/adapter/sqlite/personal-key-store.ts create mode 100644 packages/core/src/adapter/storage/personal-key-store.ts create mode 100644 packages/core/src/adapter/testing/personal-key-store.contract.ts create mode 100644 packages/core/src/kernel/events/app-event-store.ts create mode 100644 packages/core/src/kernel/personal-data/crypto.test.ts create mode 100644 packages/core/src/kernel/personal-data/crypto.ts create mode 100644 packages/core/src/kernel/personal-data/event-store.test.ts create mode 100644 packages/core/src/kernel/personal-data/event-store.ts create mode 100644 packages/core/src/kernel/personal-data/forget.test.ts create mode 100644 packages/core/src/kernel/personal-data/forget.ts create mode 100644 packages/core/src/kernel/personal-data/marks.test.ts create mode 100644 packages/core/src/kernel/personal-data/marks.ts create mode 100644 packages/core/src/kernel/personal-data/personal-data.test.ts create mode 100644 packages/core/src/memory/personal-key-store.ts create mode 100644 packages/core/src/modules/bounda-events.ts create mode 100644 packages/core/src/modules/personal.test.ts create mode 100644 packages/core/src/modules/personal.ts create mode 100644 packages/core/test-types/fixtures/order-app/app/domain/order/policies/bounda/+types/audit-on-personal-data-forgotten.ts create mode 100644 packages/core/test-types/fixtures/order-app/app/domain/order/policies/bounda/audit-on-personal-data-forgotten.ts create mode 100644 packages/core/test-types/fixtures/order-app/app/read/customer-directory/+types/view.ts create mode 100644 packages/core/test-types/fixtures/order-app/app/read/customer-directory/projections/bounda/+types/personal-data-forgotten.ts create mode 100644 packages/core/test-types/fixtures/order-app/app/read/customer-directory/projections/bounda/personal-data-forgotten.ts create mode 100644 packages/core/test-types/fixtures/order-app/app/read/customer-directory/projections/customer/+types/customer-registered.ts create mode 100644 packages/core/test-types/fixtures/order-app/app/read/customer-directory/projections/customer/customer-registered.ts create mode 100644 packages/core/test-types/fixtures/order-app/app/read/customer-directory/queries/+types/get-customer.ts create mode 100644 packages/core/test-types/fixtures/order-app/app/read/customer-directory/queries/get-customer.ts create mode 100644 packages/core/test-types/fixtures/order-app/app/read/customer-directory/view.ts create mode 100644 packages/core/test-types/fixtures/order-app/app/read/order-summary/projections/bounda/+types/personal-data-forgotten.ts create mode 100644 packages/core/test-types/fixtures/order-app/app/read/order-summary/projections/bounda/personal-data-forgotten.ts create mode 100644 packages/postgresql/src/personal-key-store.ts diff --git a/.changeset/personal-data.md b/.changeset/personal-data.md new file mode 100644 index 00000000..01577919 --- /dev/null +++ b/.changeset/personal-data.md @@ -0,0 +1,10 @@ +--- +"@bounda-dev/core": minor +"@bounda-dev/cli": minor +"@bounda-dev/sqlite": minor +"@bounda-dev/postgresql": minor +"@bounda-dev/cloudflare": minor +"@bounda-dev/react-router": minor +--- + +Personal data in events, forgotten by crypto-shredding. An event's `payload` receives `personal` and `person` next to `z`: `personal(schema)` marks a field as personal data, stored encrypted (AES-256-GCM through WebCrypto, on Node and in `workerd`) with a key of the person the event names, `person(schema)` marks the field that names them, and without it the aggregate's id does. `app.forget(personId)`, on every `BoundaClient`, deletes the key and appends `PersonalDataForgotten` to the stream `bounda:`, in one transaction: from then on every personal field of theirs reads as `null`, typed `T | null` in every handler. A read model that projects an event with personal fields must handle it from `projections/bounda/personal-data-forgotten.ts`, which the generator checks in `.bounda/types.ts`; a policy may react to it from `policies/bounda/`. The adapters keep the keys in a new `personal_keys` table, `Storage` and `StorageTransaction` gain `personalKeys`, and the storage contracts cover it. The Cloudflare Worker answers `POST /forget/`. The global stream shows the fields as stored, encrypted. diff --git a/docs/src/content/docs/adapters/cloudflare.md b/docs/src/content/docs/adapters/cloudflare.md index 0c4fa04c..241131e9 100644 --- a/docs/src/content/docs/adapters/cloudflare.md +++ b/docs/src/content/docs/adapters/cloudflare.md @@ -149,6 +149,7 @@ global stream. | --- | --- | | `POST /commands/` with the payload as the body | The dispatch result, once the read models reflect it, or once its events are stored with `createWorker({ config, tenantOf, consistency: "eventual" })`. `?delay=10m` schedules it | | `POST /queries/` with the payload as the body | The query's result | +| `POST /forget/` | [Forgets](/guides/personal-data/) the person, answering like a command | Refusals come back as `{ "error": { "code", "message" } }` with the statuses in [errors over HTTP](/reference/errors/#over-http); anything else is a 500 whose message goes to the @@ -172,8 +173,8 @@ export default { `connect(stub)` gives a `BoundaClient`, the type `@bounda-dev/core` gives to what a request sees of an app wherever it runs: the same `commands` and `queries` as `app.commands` and -`app.queries`, typed from your modules, plus `getLag()`, `events`, `deadLetters` and -`rebuildReadModel`. +`app.queries`, typed from your modules, plus `getLag()`, `events`, `deadLetters`, +`rebuildReadModel` and `forget`. Its commands resolve once the read models reflect them; `connect(stub, { consistency: "eventual" })` resolves them once their events are stored, for a request that reads nothing after it. A refusal comes back as an `Error` with the same `name`, `message`, `code` and, for validation, `issues`, whatever diff --git a/docs/src/content/docs/adapters/index.mdx b/docs/src/content/docs/adapters/index.mdx index 127abac5..f059adfd 100644 --- a/docs/src/content/docs/adapters/index.mdx +++ b/docs/src/content/docs/adapters/index.mdx @@ -88,6 +88,7 @@ On first use the adapter creates its tables, prefixed with `bounda_` by default: | `bounda_inbox` | Which handler already completed for which event, so a retry does not run it again | | `bounda_scheduled_commands` | Scheduled commands, process deadlines and timeouts, and delayed policy runs | | `bounda_dead_letters` | Handler runs that gave up, with the error and the attempt count | +| `bounda_personal_keys` | One key per person, which their [personal fields](/guides/personal-data/) are encrypted with; `forget` deletes it | Read models get one table each, named after the read model in snake_case behind `rm_`, so no read model can take one of the names above: `orderSummary` becomes `bounda_rm_order_summary`. diff --git a/docs/src/content/docs/concepts/forgetting-in-an-immutable-log.md b/docs/src/content/docs/concepts/forgetting-in-an-immutable-log.md new file mode 100644 index 00000000..ed510b7b --- /dev/null +++ b/docs/src/content/docs/concepts/forgetting-in-an-immutable-log.md @@ -0,0 +1,149 @@ +--- +title: Forgetting in an immutable log +description: What to do with personal data in events that are never deleted, when keeping it behind a port is enough, when crypto-shredding is the answer, and what Marten, Axon and KurrentDB do instead. +sidebar: + order: 52 +--- + +An event store keeps every event, forever. That is what makes a rebuild honest, an audit trail +complete and a bug reproducible. It is also what a privacy law such as the GDPR runs into head on: +a person may ask that their data be erased, and a log whose point is to never erase anything has +to answer. The answer is not to delete events. **Decide, for each field, whether it belongs in the +event at all, and when it does, make it unreadable instead of absent.** + +## Most personal data does not belong in an event + +An event records a decision of the domain. `OrderPlaced` is the fact that customer `c-42` placed +an order for two items; the customer's name, their address and their phone number are not part of +that fact. They are details the shipping provider needs, the invoice shows, the welcome email is +sent to. A command handler that puts them in the event because "they were in the command" has +made the event the owner of data that another part of the system owns. + +The first tool is a port. The event carries the id and the data lives behind a port whose +implementation can delete: + +```ts +// app/domain/customer/contact-details.ts +export interface ContactDetails { + save(customerId: string, details: { email: string; phone: string }): Promise; + find(customerId: string): Promise<{ email: string; phone: string } | null>; + erase(customerId: string): Promise; +} +``` + +A command saves the details through the port and returns `CustomerRegistered { customerId }`; a +policy that sends the welcome email reads them through the port when it runs. Erasing the person +is `erase(customerId)` on the store the implementation writes to: a table, a KV namespace, a +profile service. On Cloudflare that store is another binding, a KV or D1 next to the Durable +Object, so the object's SQLite holds nothing to forget. Mathias Verraes calls this +[forgettable payloads](https://verraes.net/2019/05/eventsourcing-patterns-forgettable-payloads): +the event keeps a reference, the referenced data has a lifetime of its own. + +This is enough when the data is not needed to replay the past: a rebuilt read model lists the +orders of `c-42` by id, and a query joins the current details through the port when it shows +them. It also keeps the data out of every copy of the stream: backups, the global stream an +operator reads, the events a policy of another aggregate receives. + +## When the event has to carry it + +Sometimes the data *is* the decision. `AddressChanged` records the address the customer moved to; +`EmailVerified` records which email was verified; a `ComplaintFiled` records what the customer +wrote. Leaving it out makes the event meaningless, and a projection that shows it would have to +call a port, which +[a projection never does](/concepts/projections-call-no-one/). Then the field stays in the +event, and forgetting it has to work on the event itself, in the log, in every backup, without +rewriting anything. + +That is crypto-shredding, Verraes's +[throw away the key](https://verraes.net/2019/05/eventsourcing-patterns-throw-away-the-key/): +every person has a key, their fields are stored encrypted with it, and forgetting the person is +deleting the key. The events stay where they were, byte for byte; what they held is noise from +then on, in the live database and in every copy taken before. Bounda does this for you: + +```ts +// app/domain/customer/address-changed.ts +import type { Event } from "./+types/address-changed"; + +export const payload = ({ z, personal }: Event.PayloadArgs) => + z.object({ address: personal(z.object({ street: z.string(), city: z.string() })) }); +``` + +`personal` marks a field; the aggregate's id names the person, or a field marked with `person` +does when the event belongs to someone else's aggregate. `app.forget("c-42")` deletes the key +and appends `PersonalDataForgotten` to the person's own stream. The +[guide](/guides/personal-data/) has the rest: the types a forgotten field takes, what read models +have to do, and where the keys live. + +## What forgetting changes for the rest of the app + +Forgetting is an event of its own because what the log held has leaked, legitimately, into +everything that was built from it. A read model that projected the address has the address in a +row; a policy that sent it to the shipping provider gave it to a system of its own. Deleting the +key makes the event unreadable and does nothing to those. + +So every read model that projects a personal field must also project `PersonalDataForgotten`, +and the generator fails the build of one that does not. A policy on the same event is where the +outside world gets told: erase the contact at the mailing provider, delete the profile the search +index holds, as [keeping an external index](/guides/calling-the-outside-world/#keeping-an-external-index) +does on the way in. + +And every handler that reads a personal field reads it as `T | null`. The `null` is not a style +choice: it is what makes the compiler find every place that assumed the data would always be +there, the projection that copied it into a `NOT NULL` column, the `evolve` that lower-cased it. +A forgotten person's events still fold, still project, still replay; they just carry a hole +where the data was. + +## How others do it + +- **Marten** rewrites. A masking rule per event type says how to change its members, and + `ApplyEventDataMasking` runs it over the streams or tenant you choose, updating the stored rows + in place; projections only show the masked values after a rebuild. There is no key: the + erasure is destructive, which Marten's own documentation lists as the reason it is still + considering crypto-shredding for a later version. +- **Axon** shreds. Its Data Protection module, a commercial add-on, marks fields with + `@PersonalData` and the subject with `@DataSubjectId`, encrypts them on the way to the store, + and `deleteKey(subjectId)` forgets. After that a `String` reads as a replacement value, `""` by + default, other references as `null`, primitives as their default. Keys live in a crypto engine + of its own: a JPA or JDBC table, Vault, a Java key store. Nothing tells the rest of the app + that a key is gone. +- **KurrentDB**, EventStoreDB's successor, deletes streams. A soft delete hides a stream and + lets it be reopened; a hard delete leaves a tombstone and the name can never be used again; + `$maxAge` and `$maxCount` hide events by age or count. All of them hide at read time: nothing + leaves the disk until a scavenge rewrites the chunks, started by an operator on every node, and + the store keeps one event per stream to say it existed. Crypto-shredding is left to the + application; Kurrent's own guidance on the GDPR is a blog post and a webinar. + +Bounda's choice sits with Axon's, with three differences. The key is per person, in the same +database as the events, so that an adapter's backup and restore keep the two together and a test +needs no second store. The forgotten value is `null`, in the type as in the data, rather than a +replacement the type hides. And forgetting is an event, because the read models and the policies +have work to do when it happens. + +## What stays outside + +- **Data in the clear elsewhere.** A field marked `personal` in one event and left plain in + another is readable through the second one. The mark is per field per event, so mark every + event that carries the data, or carry it in one. +- **What left the store.** Backups of the key table, logs that printed a payload, the external + systems a policy wrote to. The key store is a table like the others: a backup of the whole + database restores the keys too, so a backup kept past a person's erasure is itself a copy of + their data. +- **Scheduled commands and dead letters.** A command scheduled with `delay` is stored with its + payload, in the clear, until it runs, and a dead-lettered scheduled command keeps it. Keep + personal data out of delayed commands, or carry an id and resolve it when the command runs. +- **Encrypted data under the GDPR.** Whether a ciphertext without a key still counts as personal + data is a legal question Verraes raises and this page does not answer. + +## Where to read more + +- [Personal data](/guides/personal-data/): the guide, with `personal`, `person`, `forget` and + the read model's part. +- [Your projection should not call anyone](/concepts/projections-call-no-one/), for why the + data a projection shows has to be in the event. +- Mathias Verraes, + [Forgettable payloads](https://verraes.net/2019/05/eventsourcing-patterns-forgettable-payloads) + and [Crypto-shredding](https://verraes.net/2019/05/eventsourcing-patterns-throw-away-the-key/). +- Marten's [protected information](https://martendb.io/events/protection), Axon's + [data protection](https://docs.axoniq.io/axon-framework-reference/5.1/data-protection/) and + KurrentDB's [streams](https://docs.kurrent.io/server/v25.0/features/streams) and + [scavenging](https://docs.kurrent.io/server/v25.0/operations/scavenge). diff --git a/docs/src/content/docs/guides/personal-data.md b/docs/src/content/docs/guides/personal-data.md new file mode 100644 index 00000000..005376b4 --- /dev/null +++ b/docs/src/content/docs/guides/personal-data.md @@ -0,0 +1,157 @@ +--- +title: Personal data +description: Mark the fields of an event that hold personal data, say whose they are, forget a person with one call, and handle it in every read model that showed them. +sidebar: + order: 11 +--- + +Events are never deleted, so personal data in one is forgotten by making it unreadable: each +person has a key, their fields are stored encrypted with it, and `forget` throws the key away. +[Forgetting in an immutable log](/concepts/forgetting-in-an-immutable-log/) says when to do this +and when to keep the data behind a port instead. This page is the how. + +## Marking a field + +An event's `payload` receives `personal` and `person` next to `z`: + +```ts +// app/domain/customer/customer-registered.ts +import type { Event } from "./+types/customer-registered"; + +export const payload = ({ z, personal }: Event.PayloadArgs) => + z.object({ email: personal(z.email()), plan: z.enum(["free", "pro"]) }); + +export const begin = ({ event }: Event.BeginArgs) => ({ + email: event.payload.email, + plan: event.payload.plan, +}); +``` + +`personal(schema)` takes any schema: a string, a number, a whole object or an array, which is +encrypted as one value. It marks a top-level field of the payload; a mark deeper down is not seen, +so mark the field that holds the nested object. Here the person is the aggregate itself: the key +is the customer's id. When the event belongs to another aggregate, `person` names the field that +identifies them: + +```ts +// app/domain/order/order-placed.ts +import type { Event } from "./+types/order-placed"; + +export const payload = ({ z, personal, person }: Event.PayloadArgs) => + z.object({ + customerId: person(z.string()), + lines: z.array(z.object({ sku: z.string(), quantity: z.int().positive() })), + deliveryNote: personal(z.string()).optional(), + }); +``` + +An event names at most one person. Only events have the marks: a command's or a query's +`PayloadArgs` has no `personal`, since commands are not stored and nothing of theirs has to be +forgotten. + +## What the types say + +Whoever writes the event, the command handler through `events.customerRegistered({ email })`, +passes what the schema takes: a `string`. Whoever reads it, `begin`, `evolve`, a policy, a +projection, a process, sees `string | null`: the value, or `null` once the person is forgotten. +The `null` reaches the state too, so the `begin` above gives the customer an `email` of +`string | null`, and a command handler that reads `state.email` handles the case. A field +marked `.optional()` reads as `string | null | undefined`: absent, or forgotten. + +An [upcaster](/guides/changing-events/) runs on the payload after it is decrypted, so one that +reads a personal field of an old shape must take `null` there too. + +## Forgetting a person + +```ts +const forgotten = await app.forget("c-42"); +await app.catchUpReadModels({ through: forgotten }); +``` + +`forget` is on every `BoundaClient`: the app, the Durable Object through `connect`, the `bounda` +context of React Router. In one transaction it deletes the person's key and appends +`PersonalDataForgotten`, with `{ personId }` as payload, to the stream `bounda:`: the +runtime's own aggregate, `bounda`, has one stream per person it forgot. It resolves like a +command, with the event's position, so `catchUpReadModels({ through })` waits for the read +models that handle it, and the Durable Object's `forget` waits for them itself unless +`consistency` is `"eventual"`. A person without a key is forgotten all the same, since a read +model may hold what their events carried in the clear, and forgetting twice appends twice. An +empty id is refused with `ValidationError`. + +From then on every personal field of theirs reads as `null`, in every event, however old. A +person registered again gets a fresh key; their old events stay unreadable. + +## Handling it in a read model + +A read model that projects an event with personal fields has those fields in its rows. It must +project `PersonalDataForgotten` from `projections/bounda/personal-data-forgotten.ts`: + +```ts +// app/read/customer-directory/projections/bounda/personal-data-forgotten.ts +import type { Projection } from "./+types/personal-data-forgotten"; + +export const project = async ({ event, table }: Projection.Args) => { + await table.delete({ customerId: event.payload.personId }); +}; +``` + +What it does is the read model's business: delete the row, as here, keep it without the personal +columns by upserting it again without them, or keep a count. The +generator checks it: `.bounda/types.ts` declares one type per read model, +`CustomerDirectoryForgetting`, that fails to compile while the read model projects an event with +personal fields and has no such projection, naming the read model and the file to add. A read +model whose projections never see a personal field needs nothing. + +A projection of a personal event handles `null` as well, since a rebuild replays every event, the +forgotten ones included: a `string` column that is `NOT NULL` takes a `null` email badly, so make +the column optional and leave it out, as the directory above does. + +## Telling the outside world + +What a policy sent out stays out: the mailing provider has the contact, the search index has the +profile. A policy in `policies/bounda/` reacts to the event: + +```ts +// app/domain/customer/policies/bounda/erase-on-personal-data-forgotten.ts +import type { Policy } from "./+types/erase-on-personal-data-forgotten"; + +export const handler = async ({ event, mailer, idempotencyKey }: Policy.HandlerArgs) => { + await mailer.eraseContact(event.payload.personId, idempotencyKey); +}; +``` + +It runs with the aggregate's ports, retries and dead-letters as any policy, and its handler is +typed with `PersonalDataForgottenEvent`. + +## Where the keys live + +Each adapter keeps the keys in a table next to the events, `bounda_personal_keys`, one row per +person: the memory adapter in memory, SQLite and the Durable Object in their database, PostgreSQL +in its schema. A key is created when the first event with a personal field of that person is +stored, by the transaction that stores it, and deleted by `forget`. Encryption is AES-256-GCM +through WebCrypto, with a nonce per value, on Node and in `workerd` alike; what the store holds +for a personal field is an envelope with the ciphertext, the person's id and a fingerprint of the +key, which the [global stream](/guides/reading-the-stream/) shows as it is; a value sealed under a +key the person no longer has, because they were forgotten and registered again, reads as `null` +next to what their new key opens. + +Keeping the keys next to the data is a choice: a backup restores both, a test needs nothing +else, and the Durable Object has no second store to reach. It also means a backup is a copy of +the data for as long as it is kept, and that whoever reads the database reads the keys. An +adapter is free to keep them elsewhere: `Storage.personalKeys` and the key store's +[contract](/adapters/) are the whole interface. + +## In tests + +`createTestApp` forgets like any app: + +```ts +const { app } = await createTestApp({ registry }); +await app.commands.registerCustomer({ customerId: "c-1", email: "ana@example.com", plan: "pro" }); +await app.forget("c-1"); +await app.runUntilIdle(); +expect(await app.queries.getCustomer({ customerId: "c-1" })).toEqual({ customerId: "c-1" }); +expect(JSON.stringify(await app.events.read())).not.toContain("ana@example.com"); +``` + +The second assertion is the one that matters: the stream, read raw, holds nothing of the person. diff --git a/docs/src/content/docs/guides/project-layout.mdx b/docs/src/content/docs/guides/project-layout.mdx index 5ac4cf01..9b0d1105 100644 --- a/docs/src/content/docs/guides/project-layout.mdx +++ b/docs/src/content/docs/guides/project-layout.mdx @@ -114,7 +114,9 @@ export const evolve = ({ event }: Event.EvolveArgs) => ({ }); ``` -`payload` is optional; an event without one has an empty payload. An event returns the state +`payload` is optional; an event without one has an empty payload. Its `PayloadArgs` carry +`personal` and `person` besides `z`, for the fields that hold +[personal data](/guides/personal-data/). An event returns the state fields it sets, merged over the state one level deep: a field returned as `undefined` keeps its value, so clear one with `null`. `begin` opens the aggregate and gets the event alone, since there is no state before it; `evolve` folds a later event and gets the state too. An event that can diff --git a/docs/src/content/docs/guides/read-models.md b/docs/src/content/docs/guides/read-models.md index 1942a18b..a5b16c0b 100644 --- a/docs/src/content/docs/guides/read-models.md +++ b/docs/src/content/docs/guides/read-models.md @@ -44,6 +44,10 @@ export const project = async ({ event, table }: Projection.Args) => { }; ``` +A projection of an event with a field marked `personal` sees it as `T | null`, and the read model +then projects `PersonalDataForgotten` too, from `projections/bounda/personal-data-forgotten.ts`: +see [Personal data](/guides/personal-data/#handling-it-in-a-read-model). + ## Queries A query `queries/.ts` has an optional `payload`, an optional `repository` that reads diff --git a/docs/src/content/docs/guides/reading-the-stream.md b/docs/src/content/docs/guides/reading-the-stream.md index 642d73c1..7d1dd254 100644 --- a/docs/src/content/docs/guides/reading-the-stream.md +++ b/docs/src/content/docs/guides/reading-the-stream.md @@ -40,7 +40,11 @@ The lifecycle events of a process are rows like any other: `ProcessStarted`, `Pr **Payloads come as they were stored.** An event written before its type had an [upcaster](/guides/changing-events/) keeps its old shape, and `metadata.schemaVersion` says which; only handlers see it upcast. So a read shows what the store holds, and an event written by -newer code, which this code cannot upcast, does not make it fail. +newer code, which this code cannot upcast, does not make it fail. A field marked +[`personal`](/guides/personal-data/) comes as the store holds it too: an envelope with the +ciphertext and the person's id, never the value, so the stream can be read by an operator without +reading anyone's data. A `PersonalDataForgotten` event is a row like any other, on the stream +`bounda:` with `system: true`. ## Following the stream diff --git a/docs/src/content/docs/reference/conventions.md b/docs/src/content/docs/reference/conventions.md index 2f6253dd..eb5da379 100644 --- a/docs/src/content/docs/reference/conventions.md +++ b/docs/src/content/docs/reference/conventions.md @@ -23,13 +23,14 @@ Names are kebab-case. `` is a name you choose. | `app/domain//infrastructure//.ts` | `export default ... satisfies `, or `create`, typed as `CreateImplementation` | | `app/domain//commands/.ts` | `payload` (optional), `rejections` (optional), `handler` | | `app/domain//policies/-on-.ts` | `handler`; `on` and `delay` optional | -| `app/domain//policies//-on-.ts` | the same, for that aggregate's events | +| `app/domain//policies//-on-.ts` | the same, for that aggregate's events; `bounda` for the runtime's own, such as `PersonalDataForgotten` | | `app/domain//processes//index.ts` | `config` (`startedBy`, `completedBy`, `timeout`), `state` (optional), `correlate` (optional) | | `app/domain//processes//on-.ts` | `handler` | | `app/domain//processes//at-.ts`, `at-timeout.ts` | `handler` | | `app/domain//processes///on-.ts` | `handler`, for that aggregate's event | | `app/read//view.ts` | `fields` | | `app/read//projections//.ts` | `project`; `on` optional | +| `app/read//projections/bounda/personal-data-forgotten.ts` | `project`, required once a projection of the read model sees a field marked `personal` | | `app/read//queries/.ts` | `payload` (optional), `repository` (optional), `handler` | | `app/read//.ts`, `infrastructure//.ts` | as in an aggregate; only the queries' `handler` receives them | @@ -45,7 +46,8 @@ does files that start with `_` or `.`, tests (`*.test.ts`, `*.test-d.ts`), decla - a name that is not kebab-case, or a file that is not a `.ts` module where modules go; - a command or a policy that is a directory, a process that is a file, a process directory without `index.ts`, a read model without `view.ts`, a projection or a query that is a directory; -- a projection outside a folder named after an aggregate of the app; +- a projection outside a folder named after an aggregate of the app, or after `bounda` with a + name that is no event of the runtime's; an aggregate named `bounda`; - a process handler not named `on-.ts` or `at-.ts`, or one for an event its aggregate does not have; - a module at an aggregate's root that exports an event's function (`payload`, `begin`, `evolve`) @@ -64,12 +66,18 @@ does files that start with `_` or `.`, tests (`*.test.ts`, `*.test-d.ts`), decla - an aggregate or a read model whose generated types would meet others in `.bounda/types.ts`: one named `test` (`TestPorts` is Bounda's own), or `order-created` next to `order`. +One rule is checked by the compiler rather than the generator: a read model that projects an +event with a field marked `personal` and has no `projections/bounda/personal-data-forgotten.ts` +fails to compile at the `Forgetting` type of `.bounda/types.ts`, which says what to +add. See [Personal data](/guides/personal-data/#handling-it-in-a-read-model). + ## What boot refuses Some rules need the modules themselves, so the app checks them when it is created and throws a `ConfigurationError`: -- a policy or a projection whose trigger is not an event of the aggregate it listens to; +- a policy or a projection whose trigger is not an event of the aggregate it listens to, the + runtime's own `bounda` included; an event that marks two fields with `person`; - a `deadline()` field without its `at-` file, and an `at-` file without its field; the name `timeout` is reserved for the process's lifetime; - an event of another aggregate that a process listens to and cannot assign to an instance, by an @@ -103,7 +111,7 @@ and not failed on: | --- | --- | | `.bounda/registry.ts` | Every module, grouped as the runtime needs it. `boot()` imports it | | `.bounda/register.d.ts` | Registers the registry type and the type of the `ports` section with `@bounda-dev/core/register`, so `boot()` and `BoundaApp` are typed for the project without a type argument and `defineConfig` checks the implementation names | -| `.bounda/types.ts` | The state, events, ports, commands, rows and queries maps the `+types` build on | +| `.bounda/types.ts` | The state, events, ports, commands, rows and queries maps the `+types` build on, and one `Forgetting` check per read model that projects events | | `**/+types/.ts` | The argument types each module imports. An implementation has none: it imports its port | They are derived from your code, so they are not versioned. `tsconfig.json` must include them as diff --git a/docs/src/content/docs/reference/limitations.md b/docs/src/content/docs/reference/limitations.md index 1507540b..cb9909c8 100644 --- a/docs/src/content/docs/reference/limitations.md +++ b/docs/src/content/docs/reference/limitations.md @@ -27,9 +27,14 @@ get today. Each item says why, so nobody discovers it the hard way: - **Notifications for scheduled commands.** The worker that runs due commands polls at `pollInterval`; only the event dispatcher is woken by `NOTIFY`. See [Tuning](/guides/deployment/#tuning). +- **Personal data outside events.** A field marked `personal` is encrypted in the event store and + nowhere else: a command scheduled with `delay` keeps its payload in the clear until it runs, + and so does the dead letter of one that gave up. Keep personal data out of delayed commands. + See [Forgetting in an immutable log](/concepts/forgetting-in-an-immutable-log/#what-stays-outside). What a production app does get, and where it is explained: [rebuilding a read model](/guides/deployment/#rebuilding-a-read-model) without taking it offline, +[forgetting a person](/guides/personal-data/) without deleting an event, [dead letters with a way out](/guides/dead-letters/), [upcasters](/guides/changing-events/) for events whose payload changed, [observability](/reference/observability/) through OpenTelemetry, and a dispatcher that reacts in milliseconds on PostgreSQL. diff --git a/packages/cli/src/generate/discover.test.ts b/packages/cli/src/generate/discover.test.ts index 44bac8d6..09de160f 100644 --- a/packages/cli/src/generate/discover.test.ts +++ b/packages/cli/src/generate/discover.test.ts @@ -187,6 +187,11 @@ describe("discoverProject on the order-app fixture", () => { ["placeOrder", "PlaceOrder", "app/domain/order/commands/place-order.ts"], ], policies: [ + [ + "boundaAuditOnPersonalDataForgotten", + "personalDataForgotten", + "app/domain/order/policies/bounda/audit-on-personal-data-forgotten.ts", + ], [ "customerGreetOnCustomerRegistered", "customerRegistered", @@ -224,6 +229,24 @@ describe("discoverProject on the order-app fixture", () => { }, ], readModels: [ + { + name: "customerDirectory", + view: "app/read/customer-directory/view.ts", + ports: [], + projections: [ + [ + "personalDataForgotten", + "app/read/customer-directory/projections/bounda/personal-data-forgotten.ts", + ], + [ + "customerRegistered", + "app/read/customer-directory/projections/customer/customer-registered.ts", + ], + ], + queries: [ + ["getCustomer", "GetCustomer", "app/read/customer-directory/queries/get-customer.ts"], + ], + }, { name: "orderSummary", view: "app/read/order-summary/view.ts", @@ -235,6 +258,10 @@ describe("discoverProject on the order-app fixture", () => { ], ], projections: [ + [ + "personalDataForgotten", + "app/read/order-summary/projections/bounda/personal-data-forgotten.ts", + ], ["orderPaid", "app/read/order-summary/projections/order/order-paid.ts"], ["orderPlaced", "app/read/order-summary/projections/order/order-placed.ts"], ], @@ -256,6 +283,52 @@ describe("discoverProject on the order-app fixture", () => { }); }); + it("takes the runtime's events in projections/bounda and policies/bounda, and only those", async () => { + const root = await project([ + "app/domain/order/order-placed.ts", + "app/domain/order/policies/bounda/audit-on-personal-data-forgotten.ts", + "app/domain/order/policies/bounda/audit-on-personal-data-lost.ts", + "app/read/summary/view.ts", + "app/read/summary/projections/bounda/personal-data-forgotten.ts", + "app/read/summary/projections/bounda/personal-data-lost.ts", + ]); + expect(await problemsOf(root)).toEqual([ + 'app/read/summary/projections/bounda/personal-data-lost.ts: "PersonalDataLost" is not an event the runtime emits; it emits PersonalDataForgotten', + ]); + const valid = await project([ + "app/domain/order/order-placed.ts", + "app/domain/order/policies/bounda/audit-on-personal-data-forgotten.ts", + "app/domain/order/policies/bounda/audit-on-personal-data-lost.ts", + "app/read/summary/view.ts", + "app/read/summary/projections/bounda/personal-data-forgotten.ts", + ]); + const model = await discoverProject({ root: valid }); + expect(model.aggregates[0]?.policies).toMatchObject([ + { + key: "boundaAuditOnPersonalDataForgotten", + source: "bounda", + triggerKey: "personalDataForgotten", + }, + { key: "boundaAuditOnPersonalDataLost", source: "bounda", triggerKey: null }, + ]); + expect(model.warnings.map((warning) => warning.message)).toEqual([ + 'app/domain/order/policies/bounda/audit-on-personal-data-lost.ts: its name ends with no event of "bounda" after "-on-" and it exports no "on", so boot refuses it; is the event misspelled?', + ]); + expect(model.readModels[0]?.projections).toMatchObject([ + { aggregate: "bounda", eventKey: "personalDataForgotten" }, + ]); + }); + + it("refuses an aggregate named after the runtime's own", async () => { + const root = await project([ + "app/domain/bounda/forgotten.ts", + "app/domain/order/order-placed.ts", + ]); + expect(await problemsOf(root)).toEqual([ + 'app/domain/bounda: "bounda" is the runtime\'s own aggregate; give yours another name', + ]); + }); + it("ignores +types, tests, declarations and underscored files", async () => { const root = await project([ "app/domain/order/order-placed.ts", diff --git a/packages/cli/src/generate/discover.ts b/packages/cli/src/generate/discover.ts index 427073a7..a0964d92 100644 --- a/packages/cli/src/generate/discover.ts +++ b/packages/cli/src/generate/discover.ts @@ -1,11 +1,12 @@ import type { Dirent } from "node:fs"; import { readdir, readFile, stat } from "node:fs/promises"; import { basename, join, relative } from "node:path"; -import { policyTrigger } from "@bounda-dev/core"; +import { BOUNDA_AGGREGATE, BOUNDA_EVENTS, policyTrigger } from "@bounda-dev/core"; import { APP_TYPE_NAMES, createdStateTypeName, eventsTypeName, + forgettingTypeName, portsTypeName, rowTypeName, stateTypeName, @@ -222,7 +223,8 @@ const discoverPolicies = async ( ): Promise => { const listing = await list(directory); rejectOthers(context, directory, listing); - const isAggregate = (name: string): boolean => context.aggregates.has(keyOf(name)); + const isAggregate = (name: string): boolean => + context.aggregates.has(keyOf(name)) || keyOf(name) === BOUNDA_AGGREGATE; const policies = [ ...discoverPolicyModules( context, @@ -729,7 +731,8 @@ const discoverProjections = async ( const projections: ProjectionModel[] = []; for (const folder of listing.directories) { const folderPath = join(directory, folder); - if (!context.aggregates.has(keyOf(folder))) { + const ofBounda = keyOf(folder) === BOUNDA_AGGREGATE; + if (!ofBounda && !context.aggregates.has(keyOf(folder))) { context.problems.add(folderPath, `"${keyOf(folder)}" is not an aggregate of the app`); continue; } @@ -744,6 +747,15 @@ const discoverProjections = async ( for (const name of inner.modules) { const path = join(folderPath, `${name}.ts`); if (!checkName(context, path, name, "Projection")) continue; + if (ofBounda && !boundaEvents().some((event) => event.key === keyOf(name))) { + context.problems.add( + path, + `"${typeNameOf(keyOf(name))}" is not an event the runtime emits; it emits ${boundaEvents() + .map((event) => event.typeName) + .join(", ")}`, + ); + continue; + } projections.push({ ...moduleRef(context, path), aggregate: keyOf(folder), @@ -946,7 +958,14 @@ const checkTypeNames = ( for (const readModel of readModels) { // One named like an aggregate is reported as such. if (names.has(readModel.name)) continue; - declare([rowTypeName(readModel.name), portsTypeName(readModel.name)], readModel.directory); + declare( + [ + rowTypeName(readModel.name), + portsTypeName(readModel.name), + forgettingTypeName(readModel.name), + ], + readModel.directory, + ); } }; @@ -992,6 +1011,10 @@ const checkUniqueNames = ( * module that exports `on`, any event of the aggregate, since `on` is not read. Warns about one * that has neither, which boot refuses. */ +// The runtime's events, shaped as an aggregate's for what resolves policies and projections. +const boundaEvents = (): readonly Pick[] => + Object.entries(BOUNDA_EVENTS).map(([key, typeName]) => ({ key, typeName })); + const resolvePolicyTriggers = async ( context: Context, aggregates: readonly AggregateModel[], @@ -1006,7 +1029,8 @@ const resolvePolicyTriggers = async ( if (runtimeExportsOf(await readFile(policy.path, "utf8")).includes("on")) { return { ...policy, triggerKey: null }; } - const events = byName.get(source)?.events ?? []; + const events = + source === BOUNDA_AGGREGATE ? boundaEvents() : (byName.get(source)?.events ?? []); const type = policyTrigger({ key: policy.key, events: events.map((event) => event.typeName), @@ -1080,9 +1104,16 @@ export const discoverProject: DiscoverProjectFunction = async ({ root, appDir = (await exists(domain)) ? (await list(domain)).directories.map((name) => keyOf(name)) : [], ), }; - const aggregates = await discoverGroup(context, domain, "Aggregate", (directory, name) => - discoverAggregate(context, directory, name), - ); + const aggregates = await discoverGroup(context, domain, "Aggregate", (directory, name) => { + if (name === BOUNDA_AGGREGATE) { + context.problems.add( + directory, + `"${BOUNDA_AGGREGATE}" is the runtime's own aggregate; give yours another name`, + ); + return Promise.resolve(null); + } + return discoverAggregate(context, directory, name); + }); const readModels = await discoverGroup( context, join(app, READ), diff --git a/packages/cli/src/generate/emit/emit.test.ts b/packages/cli/src/generate/emit/emit.test.ts index f9876956..fd8b8732 100644 --- a/packages/cli/src/generate/emit/emit.test.ts +++ b/packages/cli/src/generate/emit/emit.test.ts @@ -428,12 +428,65 @@ export type Commands = core.CommandsFacadeOf>; export type ReactionCommands = core.ReactionCommandsFacadeOf>; +type Expect = Check; + export type ShipmentsRow = core.RowOf; export type ShipmentsPorts = core.EmptyPayload; +export type ShipmentsForgetting = Expect< + core.ForgetsPersonalData< + "shipments", + typeof import("../app/domain/shipment/created.ts"), + false + > +>; export type Queries = core.QueriesFacadeOf>; `); }); + + it("checks that a read model projecting personal data handles forgetting, over every module it projects", () => { + const shipments = model.readModels[0] as ProjectModel["readModels"][number]; + const projection = (aggregate: string, eventKey: string) => ({ + aggregate, + eventKey, + path: `/project/app/read/shipments/projections/${aggregate}/${eventKey}.ts`, + relativePath: `app/read/shipments/projections/${aggregate}/${eventKey}.ts`, + }); + const typesOf = (projections: ProjectModel["readModels"][number]["projections"]): string => + emitTypes({ + model: { ...model, readModels: [{ ...shipments, projections }] }, + path: "/project/.bounda/types.ts", + }).content; + expect( + typesOf([ + projection("bounda", "personalDataForgotten"), + projection("shipment", "created"), + projection("ticket", "byOn"), + projection("ticket", "created"), + projection("nowhere", "created"), + ]), + ).toContain(`export type ShipmentsForgetting = Expect< + core.ForgetsPersonalData< + "shipments", + | typeof import("../app/domain/shipment/created.ts") + | TicketEvents[keyof TicketEvents] + | typeof import("../app/domain/ticket/created.ts"), + true + > +>;`); + expect( + typesOf([projection("shipment", "created"), projection("shipment", "personalDataForgotten")]), + ).toContain(` | typeof import("../app/domain/shipment/created.ts") + | ShipmentEvents[keyof ShipmentEvents], + false + >`); + const onlyForgetting = typesOf([projection("bounda", "personalDataForgotten")]); + expect(onlyForgetting).not.toContain("Forgetting"); + expect(onlyForgetting).toContain( + "export type ShipmentsPorts = core.EmptyPayload;\n\nexport type Queries", + ); + expect(typesOf([])).not.toContain("Expect"); + }); }); describe("emitTypes for an app without aggregates", () => { @@ -600,6 +653,66 @@ describe("emitProject without a file-name trigger", () => { expect(ticket?.content).toContain('core.StoredEventOf'); }); + it("types a projection and a policy of the runtime's events by core's own types", () => { + const shipments = model.readModels[0] as ProjectModel["readModels"][number]; + const order = model.aggregates[0] as ProjectModel["aggregates"][number]; + const files = emitProject({ + model: { + ...model, + aggregates: [ + { + ...order, + policies: [ + { + key: "boundaAuditOnPersonalDataForgotten", + triggerKey: "personalDataForgotten", + source: "bounda", + path: "/project/app/domain/order/policies/bounda/audit-on-personal-data-forgotten.ts", + relativePath: + "app/domain/order/policies/bounda/audit-on-personal-data-forgotten.ts", + }, + { + key: "boundaAudit", + triggerKey: null, + source: "bounda", + path: "/project/app/domain/order/policies/bounda/audit.ts", + relativePath: "app/domain/order/policies/bounda/audit.ts", + }, + ], + }, + ...model.aggregates.slice(1), + ], + readModels: [ + { + ...shipments, + projections: [ + { + aggregate: "bounda", + eventKey: "personalDataForgotten", + path: "/project/app/read/shipments/projections/bounda/personal-data-forgotten.ts", + relativePath: "app/read/shipments/projections/bounda/personal-data-forgotten.ts", + }, + ], + }, + ], + }, + }); + const ofFile = (suffix: string) => + files.find((file) => slashed(file.path).endsWith(suffix))?.content; + expect(ofFile("projections/bounda/+types/personal-data-forgotten.ts")).toContain( + "core.ProjectionArgs<\n core.PersonalDataForgottenEvent,", + ); + expect(ofFile("policies/bounda/+types/audit-on-personal-data-forgotten.ts")).toContain( + "core.PolicyHandlerArgs<\n core.PersonalDataForgottenEvent,", + ); + expect(ofFile("policies/bounda/+types/audit.ts")).toContain( + "core.PolicyHandlerArgs<\n core.PersonalDataForgottenEvent,", + ); + expect(ofFile("domain/shipment/+types/created.ts")).toContain( + "type PayloadArgs = core.EventPayloadArgs;", + ); + }); + it("types a policy without -on- over every event of its aggregate", () => { const files = emitProject({ model }); const policy = files.find((file) => slashed(file.path).endsWith("policies/+types/audit.ts")); diff --git a/packages/cli/src/generate/emit/plus-types.ts b/packages/cli/src/generate/emit/plus-types.ts index dc5b43fb..410cf5bf 100644 --- a/packages/cli/src/generate/emit/plus-types.ts +++ b/packages/cli/src/generate/emit/plus-types.ts @@ -1,4 +1,5 @@ import { basename, dirname, join } from "node:path"; +import { BOUNDA_AGGREGATE, BOUNDA_EVENTS } from "@bounda-dev/core"; import type { AggregateModel, ProjectModel, ReadModelModel } from "../model.ts"; import { type GeneratedFile, importPath } from "./paths.ts"; import { @@ -73,7 +74,7 @@ const eventFiles = (aggregate: AggregateModel, typesPath: string): GeneratedFile imports: { generated: true, module: event.path }, namespace: "Event", members: [ - ["PayloadArgs", "core.PayloadArgs"], + ["PayloadArgs", "core.EventPayloadArgs"], [ "BeginArgs", generic("core.EventBeginArgs", [`"${event.typeName}"`, "core.PayloadOf"]), @@ -130,7 +131,19 @@ const storedEvent = (aggregate: AggregateModel, eventKey: string): string => const anyEvent = (aggregate: AggregateModel): string => `core.StoredEventOf`; +// Core declares each of the runtime's events as `Event`; discovery only lets known keys +// through, so an unknown one here is a policy that names no event and gets them all. +const boundaEventOf = (eventKey: string | null): string => { + const type = eventKey === null ? undefined : BOUNDA_EVENTS[eventKey]; + return type === undefined + ? Object.values(BOUNDA_EVENTS) + .map((typeName) => `core.${typeName}Event`) + .join(" | ") + : `core.${type}Event`; +}; + const eventOf = (model: ProjectModel, aggregateName: string, eventKey: string | null): string => { + if (aggregateName === BOUNDA_AGGREGATE) return boundaEventOf(eventKey); const aggregate = model.aggregates.find((candidate) => candidate.name === aggregateName); if (aggregate === undefined) return "core.StoredEvent"; return eventKey !== null && aggregate.events.some((event) => event.key === eventKey) diff --git a/packages/cli/src/generate/emit/types.ts b/packages/cli/src/generate/emit/types.ts index 1a08d7d8..ac176b26 100644 --- a/packages/cli/src/generate/emit/types.ts +++ b/packages/cli/src/generate/emit/types.ts @@ -1,3 +1,4 @@ +import { BOUNDA_AGGREGATE } from "@bounda-dev/core"; import type { AggregateModel, ProjectModel, ReadModelModel } from "../model.ts"; import { typeNameOf } from "../naming.ts"; import { type GeneratedFile, importPath } from "./paths.ts"; @@ -60,6 +61,13 @@ export interface RowTypeNameFunction { export const rowTypeName: RowTypeNameFunction = (readModelName) => `${typeNameOf(readModelName)}Row`; +export interface ForgettingTypeNameFunction { + (readModelName: string): string; +} + +export const forgettingTypeName: ForgettingTypeNameFunction = (readModelName) => + `${typeNameOf(readModelName)}Forgetting`; + export interface PortsTypeNameFunction { (moduleName: string): string; } @@ -198,6 +206,58 @@ const emitTestPorts = (model: ProjectModel): string => { ].join("\n"); }; +const FORGOTTEN_EVENT_KEY = "personalDataForgotten"; +const EXPECT_TYPE_NAME = "Expect"; + +/** + * The event modules a read model projects: each event its projections name, or every event of + * the aggregate for a projection that exports `on`. The runtime's own events carry no personal + * data, and no aggregate of the model is theirs. + */ +const projectedModules = (model: ProjectModel, readModel: ReadModelModel, path: string) => { + const sources = new Set(); + for (const projection of readModel.projections) { + const aggregate = model.aggregates.find((candidate) => candidate.name === projection.aggregate); + if (aggregate === undefined) continue; + const event = aggregate.events.find((candidate) => candidate.key === projection.eventKey); + const events = eventsTypeName(aggregate.name); + sources.add( + event === undefined ? `${events}[keyof ${events}]` : typeofImport(path, event.path), + ); + } + return [...sources]; +}; + +/** + * A type that fails to compile while the read model projects an event with personal fields and + * has no projection of `PersonalDataForgotten`; `null` for a read model that projects no module. + */ +const emitForgetting = ( + model: ProjectModel, + readModel: ReadModelModel, + path: string, +): string | null => { + const modules = projectedModules(model, readModel, path); + if (modules.length === 0) return null; + const handles = readModel.projections.some( + (projection) => + projection.aggregate === BOUNDA_AGGREGATE && projection.eventKey === FORGOTTEN_EVENT_KEY, + ); + return [ + `export type ${forgettingTypeName(readModel.name)} = ${EXPECT_TYPE_NAME}<`, + " core.ForgetsPersonalData<", + ` "${readModel.name}",`, + ...(modules.length === 1 + ? [` ${modules[0]},`] + : modules.map( + (module, index) => ` | ${module}${index === modules.length - 1 ? "," : ""}`, + )), + ` ${handles}`, + " >", + ">;", + ].join("\n"); +}; + const emitMap = ( name: string, wrapper: string, @@ -246,11 +306,16 @@ export const emitTypes: EmitTypesFunction = ({ model, path, inferredStates = {} ); sections.push(emitMap(COMMANDS_TYPE_NAME, "CommandsFacadeOf", commandModules)); sections.push(emitMap(REACTION_COMMANDS_TYPE_NAME, "ReactionCommandsFacadeOf", commandModules)); - for (const readModel of model.readModels) { + const forgetting = model.readModels.map((readModel) => emitForgetting(model, readModel, path)); + if (forgetting.some((check) => check !== null)) { + sections.push(`type ${EXPECT_TYPE_NAME} = Check;`); + } + for (const [index, readModel] of model.readModels.entries()) { sections.push( [ `export type ${rowTypeName(readModel.name)} = core.RowOf<${typeofImport(path, readModel.view.path)}>;`, emitPorts(readModel, path), + ...(forgetting[index] === null ? [] : [forgetting[index]]), ].join("\n"), ); } diff --git a/packages/cloudflare/src/bounda-object.ts b/packages/cloudflare/src/bounda-object.ts index 0e8ce128..0f29e026 100644 --- a/packages/cloudflare/src/bounda-object.ts +++ b/packages/cloudflare/src/bounda-object.ts @@ -17,6 +17,7 @@ import { type RebuildReadModelResult, RebuildSupersededError, type Registry, + type StoredDispatch, type StoredEvent, systemClock, } from "@bounda-dev/core"; @@ -85,6 +86,11 @@ export interface BoundaObjectMethods { * the rebuilt table takes the live one's place; queries keep reading the live table meanwhile. */ rebuildReadModel(name: string): Promise>; + /** + * Forgets a person, as `BoundaClient.forget` does, and like `command` waits for the read models + * that handle it unless `consistency` is `"eventual"`. + */ + forget(personId: string, consistency?: Consistency): Promise>; /** * Runs the next slice of every paused rebuild, then policies, processes, due scheduled commands * and retries, in bounded slices, and arms the next alarm. Called by the platform. @@ -290,6 +296,16 @@ export const createBoundaObject: CreateBoundaObjectFunction = > { + return settle(async () => { + const app = this.#ready(); + const result = await app.forget(personId); + if (consistency !== "eventual") await app.catchUpReadModels({ through: result }); + await this.#rearm(false, true); + return result; + }); + } + override async alarm(): Promise { let idle = true; let rebuildFailed = false; diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index 091172ef..911241a1 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -13,6 +13,7 @@ import { type ReadEventsArgs, type RebuildReadModelResult, type Registry, + type StoredDispatch, type StoredEvent, } from "@bounda-dev/core"; import { unwrap } from "./outcome.ts"; @@ -36,6 +37,7 @@ export interface BoundaStub { retryDeadLetter(id: string): PromiseLike; discardDeadLetter(id: string): PromiseLike; rebuildReadModel(name: string): PromiseLike; + forget(personId: string, consistency?: Consistency): PromiseLike; } export interface ConnectOptions { @@ -101,5 +103,6 @@ export const connect: ConnectFunction = ( discard: (id) => unwrap(stub.discardDeadLetter(id)), }, rebuildReadModel: (name) => unwrap(stub.rebuildReadModel(name)), + forget: (personId) => unwrap(stub.forget(personId, consistency)), }; }; diff --git a/packages/cloudflare/src/worker.ts b/packages/cloudflare/src/worker.ts index 9327bd31..48dc937c 100644 --- a/packages/cloudflare/src/worker.ts +++ b/packages/cloudflare/src/worker.ts @@ -74,8 +74,16 @@ const optionsOf = (url: URL): DispatchOptions => { }; const route = (pathname: string): { readonly kind: string; readonly name: string } | null => { - const match = /^\/(commands|queries)\/([A-Za-z][A-Za-z0-9]*)\/?$/.exec(pathname); - return match === null ? null : { kind: match[1] as string, name: match[2] as string }; + const match = + /^\/(commands|queries)\/([A-Za-z][A-Za-z0-9]*)\/?$/.exec(pathname) ?? + /^\/(forget)\/([^/]+)\/?$/.exec(pathname); + if (match === null) return null; + try { + return { kind: match[1] as string, name: decodeURIComponent(match[2] as string) }; + } catch { + // A malformed escape names no one. + return null; + } }; /** @@ -84,7 +92,8 @@ const route = (pathname: string): { readonly kind: string; readonly name: string * * - `POST /commands/` with the payload as the body, `?delay=10m` to schedule it, answers * the dispatch result, once the read models reflect it unless `consistency` is `"eventual"`; - * - `POST /queries/` with the payload as the body answers the query's result. + * - `POST /queries/` with the payload as the body answers the query's result; + * - `POST /forget/` forgets a person, as `forget` does, and answers like a command. * * Refusals come back as `{ error: { code, message } }`: 400 for a payload that fails validation, * with `issues`, 404 for an unknown command, query or row, 409 for a command's rejection, with its @@ -111,7 +120,7 @@ export const createWorker: CreateWorkerFunction = ({ } let payload: unknown; try { - payload = await readPayload(request); + payload = target.kind === "forget" ? undefined : await readPayload(request); } catch { return failure("INVALID_JSON", "The body is not JSON", 400); } @@ -127,7 +136,9 @@ export const createWorker: CreateWorkerFunction = ({ const result = await unwrap( target.kind === "commands" ? stub.command(target.name, payload, optionsOf(url), consistency) - : stub.query(target.name, payload), + : target.kind === "queries" + ? stub.query(target.name, payload) + : stub.forget(target.name, consistency), ); return json(result ?? null); } catch (error) { diff --git a/packages/cloudflare/test/app.ts b/packages/cloudflare/test/app.ts index 2e9f3e75..4bb403a9 100644 --- a/packages/cloudflare/test/app.ts +++ b/packages/cloudflare/test/app.ts @@ -2,6 +2,7 @@ import { type CreateArgs, DomainError, + type EventPayloadArgs, type FieldsArgs, type Instant, type PayloadArgs, @@ -22,6 +23,7 @@ interface OrderRow { readonly orderId: string; readonly status: string; readonly total: number; + readonly note?: string; } interface CustomerRow { @@ -50,7 +52,12 @@ export const registry = { state: { initialState: { status: "new" } satisfies OrderState }, events: { orderPlaced: { - payload: ({ z }: PayloadArgs) => z.object({ total: z.number(), customer: z.string() }), + payload: ({ z, personal, person }: EventPayloadArgs) => + z.object({ + total: z.number(), + customer: person(z.string()), + note: personal(z.string()).optional(), + }), evolve: ({ state }: { state: OrderState }) => ({ ...state, status: "placed" as const }), }, orderPaid: { @@ -64,7 +71,12 @@ export const registry = { placeOrder: { module: { payload: ({ z }: PayloadArgs) => - z.object({ orderId: z.string(), total: z.number(), customer: z.string() }), + z.object({ + orderId: z.string(), + total: z.number(), + customer: z.string(), + note: z.string().optional(), + }), rejections: () => ({ AlreadyPlaced: "Order already placed" }), handler: ({ command, @@ -72,17 +84,15 @@ export const registry = { events, reject, }: { - command: { payload: { total: number; customer: string } }; + command: { payload: { total: number; customer: string; note?: string } }; state: OrderState; events: Events; reject: RejectFunction<"AlreadyPlaced">; }) => { if (state.status !== "new") return reject("AlreadyPlaced"); + const { total, customer, note } = command.payload; return [ - events.orderPlaced?.({ - total: command.payload.total, - customer: command.payload.customer, - }), + events.orderPlaced?.({ total, customer, ...(note === undefined ? {} : { note }) }), ]; }, }, @@ -151,6 +161,7 @@ export const registry = { orderId: f.string().primaryKey(), status: f.string(), total: f.number(), + note: f.string().optional(), }), }, projections: { @@ -160,14 +171,16 @@ export const registry = { event, table, }: { - event: { aggregateId: string; payload: { total: number } }; + event: { aggregateId: string; payload: { total: number; note?: string | null } }; table: Table; }) => { if (outage.projection) throw new Error("projection is down"); + const { total, note } = event.payload; await table.upsert({ orderId: event.aggregateId, status: "placed", - total: event.payload.total, + total, + ...(note === undefined || note === null ? {} : { note }), }); }, }, @@ -313,8 +326,32 @@ export const slicedRegistry = { }, }, }, + bounda: { + personalDataForgotten: { + project: async ({ + event, + table, + }: { + event: { payload: { personId: string } }; + table: Table; + }) => { + await table.delete({ customer: event.payload.personId }); + }, + }, + }, + }, + queries: { + getCustomer: { + payload: ({ z }: PayloadArgs) => z.object({ customer: z.string() }), + handler: ({ + query, + table, + }: { + query: { payload: { customer: string } }; + table: Table; + }) => table.findOne({ customer: query.payload.customer }), + }, }, - queries: {}, }, }, } satisfies Registry; diff --git a/packages/cloudflare/test/bounda-object.test.ts b/packages/cloudflare/test/bounda-object.test.ts index 370bb1d0..1d49204d 100644 --- a/packages/cloudflare/test/bounda-object.test.ts +++ b/packages/cloudflare/test/bounda-object.test.ts @@ -5,7 +5,13 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { configForObject } from "../src/bounda-object.ts"; import { connect } from "../src/client.ts"; import { cloudflare } from "../src/definition.ts"; -import { outage, type processRegistry, type quietRegistry, type registry } from "./app.ts"; +import { + outage, + type processRegistry, + type quietRegistry, + type registry, + type slicedRegistry, +} from "./app.ts"; import { clock } from "./clock.ts"; import { logs } from "./logs.ts"; import { QuietStore } from "./test-worker.ts"; @@ -65,6 +71,60 @@ describe("a Bounda Durable Object", () => { }); }); + it("seals personal fields in its SQLite, projects them in the clear, and forgets a person", async () => { + const { stub, store } = open(); + await store.commands.placeOrder({ orderId: "o-1", total: 7, customer: "ada", note: "no bell" }); + expect(await store.queries.getOrder({ orderId: "o-1" })).toMatchObject({ note: "no bell" }); + const [raw] = await store.events.read(); + expect(raw?.payload).toMatchObject({ total: 7, customer: "ada" }); + expect(JSON.stringify(raw)).not.toContain("no bell"); + const sealed = await runInDurableObject(stub as unknown as DurableObjectStub, (_i, state) => + state.storage.sql.exec(`SELECT "person" FROM bounda_personal_keys`).toArray(), + ); + expect(sealed).toEqual([{ person: "ada" }]); + + const forgotten = await store.forget("ada"); + expect(forgotten).toMatchObject({ + aggregateType: "bounda", + aggregateId: "ada", + eventTypes: ["PersonalDataForgotten"], + position: 2, + }); + expect((await store.events.read()).map((event) => event.type)).toEqual([ + "OrderPlaced", + "PersonalDataForgotten", + ]); + expect(await store.rebuildReadModel("orders")).toMatchObject({ done: true }); + expect(await store.queries.getOrder({ orderId: "o-1" })).toEqual({ + orderId: "o-1", + status: "placed", + total: 7, + }); + }); + + it("forgets with the caller's consistency, and leaves its alarm clear once the read models caught up", async () => { + const stub = sliced(); + const store = connect(stub); + await store.commands.placeOrder({ orderId: "o-1", total: 5, customer: "ada" }); + await store.commands.placeOrder({ orderId: "o-2", total: 5, customer: "bo" }); + // In one event of the object, its alarm cannot run between the forget and the query. + const seen = await runInDurableObject(stub, async (instance, state) => ({ + forgotten: await instance.forget("ada", "eventual"), + found: await instance.query("getCustomer", { customer: "ada" }), + alarm: await state.storage.getAlarm(), + })); + expect(seen.forgotten).toMatchObject({ ok: true, value: { aggregateId: "ada", version: 1 } }); + expect(seen.found).toEqual({ ok: true, value: { customer: "ada", lastOrderId: "o-1" } }); + expect(seen.alarm).toBeLessThanOrEqual(Date.now()); + await runAlarm(stub); + expect(await store.queries.getCustomer({ customer: "ada" })).toBeNull(); + + await store.forget("bo"); + expect(await store.queries.getCustomer({ customer: "bo" })).toBeNull(); + expect((await store.getLag()).maxLag).toBe(0); + expect(await alarmOf(stub)).toBeNull(); + }); + it("checks a command's signal before calling the object, which cannot receive it", async () => { const { store } = open(); const reason = new Error("client gave up"); diff --git a/packages/cloudflare/test/client-types.test.ts b/packages/cloudflare/test/client-types.test.ts index 4b88557c..8d2c5a25 100644 --- a/packages/cloudflare/test/client-types.test.ts +++ b/packages/cloudflare/test/client-types.test.ts @@ -22,6 +22,7 @@ describe("connect", () => { orderId: string; total: number; customer: string; + note?: string | undefined; }>(); expectTypeOf>().toEqualTypeOf< Promise diff --git a/packages/cloudflare/test/client.test.ts b/packages/cloudflare/test/client.test.ts index 6836739d..1f78d55e 100644 --- a/packages/cloudflare/test/client.test.ts +++ b/packages/cloudflare/test/client.test.ts @@ -22,6 +22,17 @@ describe("connect", () => { ]); }); + it("forgets a person with the same consistency as its commands", async () => { + const eventual = recordingStub(); + await connect(eventual.stub, { consistency: "eventual" }).forget("c-1"); + const byDefault = recordingStub(); + await connect(byDefault.stub).forget("c-2"); + expect([eventual.forgets, byDefault.forgets]).toEqual([ + [["c-1", "eventual"]], + [["c-2", "read-your-writes"]], + ]); + }); + it("is not taken for a promise, and has no members but named calls", async () => { const store = connect(recordingStub().stub); expect(Reflect.get(store.commands, "then")).toBeUndefined(); diff --git a/packages/cloudflare/test/recording-stub.ts b/packages/cloudflare/test/recording-stub.ts index 429d1950..7b585323 100644 --- a/packages/cloudflare/test/recording-stub.ts +++ b/packages/cloudflare/test/recording-stub.ts @@ -4,16 +4,26 @@ import type { BoundaStub } from "../src/client.ts"; * A stand-in for a Bounda object's stub that records the arguments of every command and answers * each call with an empty success. */ -export const recordingStub = (): { readonly stub: BoundaStub; readonly commands: unknown[][] } => { +export const recordingStub = (): { + readonly stub: BoundaStub; + readonly commands: unknown[][]; + readonly forgets: unknown[][]; +} => { const commands: unknown[][] = []; + const forgets: unknown[][] = []; const answer = async () => ({ ok: true, value: null }); return { commands, + forgets, stub: { command: async (...args) => { commands.push(args); return answer(); }, + forget: async (...args) => { + forgets.push(args); + return answer(); + }, query: answer, lag: answer, readEvents: answer, diff --git a/packages/cloudflare/test/storage-contracts.test.ts b/packages/cloudflare/test/storage-contracts.test.ts index 62b72331..c70d2975 100644 --- a/packages/cloudflare/test/storage-contracts.test.ts +++ b/packages/cloudflare/test/storage-contracts.test.ts @@ -9,6 +9,7 @@ import { deadLetterStoreContract, eventStoreContract, inboxLedgerContract, + personalKeyStoreContract, readModelRebuildContract, readModelTransactionContract, schedulerContract, @@ -37,6 +38,7 @@ describe("the Durable Object adapter", () => { deadLetterStoreContract({ create: async () => (await freshStorage()).deadLetterStore }); schedulerContract({ create: async () => (await freshStorage()).scheduler }); storageTransactionContract({ create: freshStorage }); + personalKeyStoreContract({ create: async () => (await freshStorage()).personalKeys }); tableContract({ create: async () => ( diff --git a/packages/cloudflare/test/worker.test.ts b/packages/cloudflare/test/worker.test.ts index 7949cfeb..2fd38f57 100644 --- a/packages/cloudflare/test/worker.test.ts +++ b/packages/cloudflare/test/worker.test.ts @@ -22,7 +22,7 @@ const post = (path: string, body?: unknown, tenant?: string) => // One request through a worker whose binding is a spy that hands out a recording stub. const throughSpy = async (path: string, body: string, consistency?: Consistency) => { - const { stub, commands } = recordingStub(); + const { stub, commands, forgets } = recordingStub(); const addressed: string[] = []; const spy = { idFromName: (name: string) => { @@ -44,7 +44,7 @@ const throughSpy = async (path: string, body: string, consistency?: Consistency) { SPY: spy } as unknown as Cloudflare.Env, createExecutionContext(), ); - return { status: response?.status, addressed, commands }; + return { status: response?.status, addressed, commands, forgets }; }; describe("createWorker", () => { @@ -72,11 +72,13 @@ describe("createWorker", () => { status: 200, addressed: ["acme"], commands: [["payOrder", { orderId: "o-1" }, {}, "eventual"]], + forgets: [], }); expect(await throughSpy("/commands/payOrder", payOrder)).toEqual({ status: 200, addressed: ["acme"], commands: [["payOrder", { orderId: "o-1" }, {}, "read-your-writes"]], + forgets: [], }); }); @@ -229,14 +231,32 @@ describe("createWorker", () => { }); }); - it("only knows POST on its two routes", async () => { + it("forgets a person through the binding, with the worker's consistency", async () => { + const byDefault = await throughSpy("/forget/ada%20b", ""); + expect(byDefault.status).toBe(200); + expect(byDefault.addressed).toEqual(["acme"]); + expect(byDefault.forgets).toEqual([["ada b", "read-your-writes"]]); + const eventual = await throughSpy("/forget/ada/", "{not json", "eventual"); + expect(eventual.status).toBe(200); + expect(eventual.forgets).toEqual([["ada", "eventual"]]); + }); + + it("only knows POST on its three routes", async () => { const root = await exports.default.fetch(new Request("https://bounda.test/")); expect(root.status).toBe(404); expect(await root.json()).toEqual({ error: { code: "NOT_FOUND", message: "No route for /" } }); const get = await exports.default.fetch(new Request("https://bounda.test/queries/getOrder")); expect(get.status).toBe(405); expect(get.headers.get("allow")).toBe("POST"); - for (const path of ["/api/queries/getOrder", "/queries/getOrder/extra", "/queries/1st"]) { + for (const path of [ + "/api/queries/getOrder", + "/queries/getOrder/extra", + "/queries/1st", + "/forget", + "/forget/", + "/forget/a/b", + "/forget/%E0%A4%A", + ]) { const response = await post(path, { orderId: "o-1" }); expect(response.status).toBe(404); expect(await response.json()).toEqual({ diff --git a/packages/core/src/adapter/adapter.ts b/packages/core/src/adapter/adapter.ts index f987cff8..d5dbd8e8 100644 --- a/packages/core/src/adapter/adapter.ts +++ b/packages/core/src/adapter/adapter.ts @@ -6,6 +6,7 @@ import type { DeadLetterStore } from "./storage/dead-letter-store.ts"; import type { EventNotifier } from "./storage/event-notifier.ts"; import type { EventStore } from "./storage/event-store.ts"; import type { InboxLedger } from "./storage/inbox-ledger.ts"; +import type { PersonalKeyStore } from "./storage/personal-key-store.ts"; import type { Scheduler } from "./storage/scheduler.ts"; import type { ReadClient, Table } from "./storage/table.ts"; @@ -21,6 +22,7 @@ export interface StorageTransaction { readonly inboxLedger: InboxLedger; readonly deadLetterStore: DeadLetterStore; readonly scheduler: Scheduler; + readonly personalKeys: PersonalKeyStore; } /** @@ -32,6 +34,7 @@ export interface Storage { readonly inboxLedger: InboxLedger; readonly deadLetterStore: DeadLetterStore; readonly scheduler: Scheduler; + readonly personalKeys: PersonalKeyStore; /** * Only for a backend that can push new events; without it the dispatcher only polls. */ diff --git a/packages/core/src/adapter/index.ts b/packages/core/src/adapter/index.ts index 9fd61312..c788e38b 100644 --- a/packages/core/src/adapter/index.ts +++ b/packages/core/src/adapter/index.ts @@ -45,6 +45,7 @@ export type { RenewClaimArgs, SettleClaimArgs, } from "./storage/inbox-ledger.ts"; +export type { PersonalKeyStore } from "./storage/personal-key-store.ts"; export type { ClaimDueArgs, ClaimedCommand, diff --git a/packages/core/src/adapter/sqlite/adapter.ts b/packages/core/src/adapter/sqlite/adapter.ts index 979d9a0a..67c131d3 100644 --- a/packages/core/src/adapter/sqlite/adapter.ts +++ b/packages/core/src/adapter/sqlite/adapter.ts @@ -9,6 +9,7 @@ import { createSqliteCheckpointStore } from "./checkpoint-store.ts"; import { createSqliteDeadLetterStore } from "./dead-letter-store.ts"; import { createSqliteEventStore } from "./event-store.ts"; import { createSqliteInboxLedger } from "./inbox-ledger.ts"; +import { createSqlitePersonalKeyStore } from "./personal-key-store.ts"; import { openSqliteReadModel, rebuildSqliteReadModel } from "./read-model.ts"; import { createSqliteScheduler } from "./scheduler.ts"; import { ensureStorageSchema, storageTablesFor } from "./schema.ts"; @@ -87,6 +88,7 @@ export const createSqliteAdapter: CreateSqliteAdapterFunction = < inboxLedger: createSqliteInboxLedger({ db: database, table: tables.inbox }), deadLetterStore: createSqliteDeadLetterStore({ db: database, table: tables.deadLetters }), scheduler: createSqliteScheduler({ db: database, table: tables.scheduledCommands }), + personalKeys: createSqlitePersonalKeyStore({ db: database, table: tables.personalKeys }), }); // The stores over an open transaction: their statements join it, and a `write` of their own // runs inside it instead of opening another, which SQLite would refuse. diff --git a/packages/core/src/adapter/sqlite/personal-key-store.ts b/packages/core/src/adapter/sqlite/personal-key-store.ts new file mode 100644 index 00000000..0e012acc --- /dev/null +++ b/packages/core/src/adapter/sqlite/personal-key-store.ts @@ -0,0 +1,34 @@ +import type { SqlExecutor } from "../sql/sql-table.ts"; +import type { PersonalKeyStore } from "../storage/personal-key-store.ts"; + +export interface CreateSqlitePersonalKeyStoreArgs { + readonly db: SqlExecutor; + readonly table: string; +} + +export interface CreateSqlitePersonalKeyStoreFunction { + (args: CreateSqlitePersonalKeyStoreArgs): PersonalKeyStore; +} + +export const createSqlitePersonalKeyStore: CreateSqlitePersonalKeyStoreFunction = ({ + db, + table, +}) => { + const get = async (person: string): Promise => { + const [row] = await db.all(`SELECT "key" FROM ${table} WHERE "person" = ?`, [person]); + return row === undefined ? null : String(row.key); + }; + return { + get, + add: async (person, key) => { + const [row] = await db.all( + `INSERT INTO ${table} ("person", "key") VALUES (?, ?) ON CONFLICT ("person") DO NOTHING RETURNING "key"`, + [person, key], + ); + return row === undefined ? ((await get(person)) ?? key) : String(row.key); + }, + remove: async (person) => + (await db.all(`DELETE FROM ${table} WHERE "person" = ? RETURNING "person"`, [person])) + .length === 1, + }; +}; diff --git a/packages/core/src/adapter/sqlite/schema.ts b/packages/core/src/adapter/sqlite/schema.ts index 87e29544..d406577b 100644 --- a/packages/core/src/adapter/sqlite/schema.ts +++ b/packages/core/src/adapter/sqlite/schema.ts @@ -8,6 +8,7 @@ export interface StorageTables { readonly inbox: string; readonly deadLetters: string; readonly scheduledCommands: string; + readonly personalKeys: string; } export interface StorageTablesForFunction { @@ -20,6 +21,7 @@ export const storageTablesFor: StorageTablesForFunction = (prefix) => ({ inbox: quoteIdentifier(storageTableNameFor({ prefix, table: "inbox" })), deadLetters: quoteIdentifier(storageTableNameFor({ prefix, table: "deadLetters" })), scheduledCommands: quoteIdentifier(storageTableNameFor({ prefix, table: "scheduledCommands" })), + personalKeys: quoteIdentifier(storageTableNameFor({ prefix, table: "personalKeys" })), }); const indexName = (table: string, suffix: string): string => @@ -100,6 +102,10 @@ export const storageSchemaStatements: StorageSchemaStatementsFunction = (tables) "claim_id" TEXT )`, `CREATE INDEX IF NOT EXISTS ${indexName(tables.scheduledCommands, "execute_at")} ON ${tables.scheduledCommands} ("execute_at")`, + `CREATE TABLE IF NOT EXISTS ${tables.personalKeys} ( + "person" TEXT PRIMARY KEY, + "key" TEXT NOT NULL + )`, ]; export interface EnsureStorageSchemaArgs { diff --git a/packages/core/src/adapter/sqlite/sqlite.test.ts b/packages/core/src/adapter/sqlite/sqlite.test.ts index 39fd1817..e4a21773 100644 --- a/packages/core/src/adapter/sqlite/sqlite.test.ts +++ b/packages/core/src/adapter/sqlite/sqlite.test.ts @@ -14,6 +14,7 @@ import { eventStoreContract, inboxLedgerContract, pendingEvent, + personalKeyStoreContract, readModelRebuildContract, readModelTransactionContract, schedulerContract, @@ -99,6 +100,7 @@ describe("the SQLite stores on node:sqlite", () => { deadLetterStoreContract({ create: async () => (await storage()).deadLetterStore }); schedulerContract({ create: async () => (await storage()).scheduler }); storageTransactionContract({ create: storage }); + personalKeyStoreContract({ create: async () => (await storage()).personalKeys }); tableContract({ create: async () => ( @@ -240,6 +242,7 @@ describe("createSqliteAdapter", () => { "app_dead_letters", "app_events", "app_inbox", + "app_personal_keys", "app_rm_order_summary", "app_scheduled_commands", ]); diff --git a/packages/core/src/adapter/storage/personal-key-store.ts b/packages/core/src/adapter/storage/personal-key-store.ts new file mode 100644 index 00000000..caa6c9c0 --- /dev/null +++ b/packages/core/src/adapter/storage/personal-key-store.ts @@ -0,0 +1,12 @@ +/** + * The keys the personal fields of events are encrypted with, one per person, as opaque text the + * kernel chooses. `get` is `null` for a person without one, as for one forgotten. `add` stores + * `key` for `person` unless one is in force already and resolves with the one in force, so two + * writers that find none keep the same key. `remove` forgets the key and reports whether there + * was one; what was encrypted with it can never be read again. + */ +export interface PersonalKeyStore { + get(person: string): Promise; + add(person: string, key: string): Promise; + remove(person: string): Promise; +} diff --git a/packages/core/src/adapter/testing/index.ts b/packages/core/src/adapter/testing/index.ts index 37317c73..ee0bf59e 100644 --- a/packages/core/src/adapter/testing/index.ts +++ b/packages/core/src/adapter/testing/index.ts @@ -21,6 +21,11 @@ export type { InboxLedgerContractFunction, } from "./inbox-ledger.contract.ts"; export { inboxLedgerContract } from "./inbox-ledger.contract.ts"; +export type { + PersonalKeyStoreContractArgs, + PersonalKeyStoreContractFunction, +} from "./personal-key-store.contract.ts"; +export { personalKeyStoreContract } from "./personal-key-store.contract.ts"; export type { ReadModelRebuildContractArgs, ReadModelRebuildContractFunction, diff --git a/packages/core/src/adapter/testing/personal-key-store.contract.ts b/packages/core/src/adapter/testing/personal-key-store.contract.ts new file mode 100644 index 00000000..8c2f15ff --- /dev/null +++ b/packages/core/src/adapter/testing/personal-key-store.contract.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import type { PersonalKeyStore } from "../storage/personal-key-store.ts"; + +export interface PersonalKeyStoreContractArgs { + readonly create: () => Promise; +} + +export interface PersonalKeyStoreContractFunction { + (args: PersonalKeyStoreContractArgs): void; +} + +/** + * The behaviour every personal key store must exhibit. Call it inside a `describe` of the + * adapter's test file with a factory that returns a fresh, empty store. + */ +export const personalKeyStoreContract: PersonalKeyStoreContractFunction = ({ create }) => { + describe("personal key store contract", () => { + let store: PersonalKeyStore; + + beforeEach(async () => { + store = await create(); + }); + + it("has no key for a person it never met", async () => { + expect(await store.get("c-1")).toBeNull(); + expect(await store.remove("c-1")).toBe(false); + }); + + it("keeps the first key added for a person and answers every add with it", async () => { + expect(await store.add("c-1", "k-first")).toBe("k-first"); + expect(await store.add("c-1", "k-second")).toBe("k-first"); + expect(await store.get("c-1")).toBe("k-first"); + expect(await store.add("c-2", "k-other")).toBe("k-other"); + expect(await store.get("c-2")).toBe("k-other"); + }); + + it("keeps one key when several writers add one at once", async () => { + const added = await Promise.all(["k-a", "k-b", "k-c"].map((key) => store.add("c-1", key))); + const kept = await store.get("c-1"); + expect(kept).not.toBeNull(); + expect(added).toEqual([kept, kept, kept]); + }); + + it("forgets a key for good, and takes a new one for the same person afterwards", async () => { + await store.add("c-1", "k-first"); + expect(await store.remove("c-1")).toBe(true); + expect(await store.get("c-1")).toBeNull(); + expect(await store.remove("c-1")).toBe(false); + expect(await store.add("c-1", "k-again")).toBe("k-again"); + expect(await store.get("c-1")).toBe("k-again"); + }); + }); +}; diff --git a/packages/core/src/adapter/testing/storage-transaction.contract.ts b/packages/core/src/adapter/testing/storage-transaction.contract.ts index bc01cad7..cdf80a05 100644 --- a/packages/core/src/adapter/testing/storage-transaction.contract.ts +++ b/packages/core/src/adapter/testing/storage-transaction.contract.ts @@ -96,6 +96,7 @@ export const storageTransactionContract: StorageTransactionContractFunction = ({ await expect( storage.transact(async (tx) => { await tx.inboxLedger.complete(key); + await tx.personalKeys.add("c-9", "k-9"); await tx.scheduler.schedule({ dedupeKey: "command:c1", command: testCommand("1"), @@ -111,6 +112,7 @@ export const storageTransactionContract: StorageTransactionContractFunction = ({ }), ).rejects.toBeInstanceOf(ConcurrencyError); await nothingWritten(); + expect(await storage.personalKeys.get("c-9")).toBeNull(); }); it("writes nothing when the scheduler entry it settles was claimed again", async () => { @@ -224,6 +226,34 @@ export const storageTransactionContract: StorageTransactionContractFunction = ({ ]); }); + it("adds and removes personal keys with the rest, or not at all", async () => { + await storage.personalKeys.add("c-1", "k-1"); + const work = async (tx: StorageTransaction) => { + expect(await tx.personalKeys.remove("c-1")).toBe(true); + expect(await tx.personalKeys.add("c-2", "k-2")).toBe("k-2"); + expect(await tx.personalKeys.get("c-2")).toBe("k-2"); + await tx.eventStore.append({ + aggregateType: "order", + aggregateId: "1", + expectedVersion: 0, + events: [pendingEvent({ aggregateId: "1", version: 1 })], + }); + }; + await expect( + storage.transact(async (tx) => { + await work(tx); + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + expect(await storage.personalKeys.get("c-1")).toBe("k-1"); + expect(await storage.personalKeys.get("c-2")).toBeNull(); + expect(await storage.eventStore.lastPosition()).toBe(0); + await storage.transact(work); + expect(await storage.personalKeys.get("c-1")).toBeNull(); + expect(await storage.personalKeys.get("c-2")).toBe("k-2"); + expect(await storage.eventStore.readAll({ afterPosition: 0, limit: 10 })).toHaveLength(1); + }); + it("changes and removes dead letters with the rest, or not at all", async () => { await storage.deadLetterStore.add(testDeadLetter("d1")); await storage.deadLetterStore.add(testDeadLetter("d2")); diff --git a/packages/core/src/kernel/aggregate/build-aggregates.ts b/packages/core/src/kernel/aggregate/build-aggregates.ts index 742673ba..11540c9d 100644 --- a/packages/core/src/kernel/aggregate/build-aggregates.ts +++ b/packages/core/src/kernel/aggregate/build-aggregates.ts @@ -2,8 +2,10 @@ import { z } from "zod"; import { ConfigurationError } from "../../contracts/errors.ts"; import { createEventBuilders } from "../../modules/event.ts"; import { capitalize } from "../../modules/naming.ts"; -import type { PayloadFunction } from "../../modules/payload.ts"; +import type { EventPayloadArgs } from "../../modules/payload.ts"; +import { person, personal } from "../../modules/personal.ts"; import type { AggregateEntry, Registry } from "../../modules/registry.ts"; +import { personalMarksOf } from "../personal-data/marks.ts"; import type { ModulePorts } from "../ports/ports.ts"; import type { AggregateRuntime, @@ -12,9 +14,12 @@ import type { EventRuntime, } from "./runtime.ts"; -const compileSchema = (payload: PayloadFunction | undefined, path: string): z.ZodType | null => { +const compileSchema = ( + payload: ((args: EventPayloadArgs) => unknown) | undefined, + path: string, +): z.ZodType | null => { if (payload === undefined) return null; - const schema = payload({ z }); + const schema = payload({ z, personal, person }); if (!(schema instanceof z.ZodType)) { throw new ConfigurationError(`${path}: payload must return a Zod schema`); } @@ -25,16 +30,19 @@ const buildEvents = (name: string, entry: AggregateEntry): Record { const upcasts = entry.upcasts?.[key]?.upcasts ?? []; + const path = `aggregates.${name}.events.${key}`; + const schema = compileSchema(module.payload, path); return [ key, { key, type: capitalize(key), - schema: compileSchema(module.payload, `aggregates.${name}.events.${key}`), + schema, begin: (module.begin ?? null) as EventRuntime["begin"], evolve: (module.evolve ?? null) as EventRuntime["evolve"], upcasts, schemaVersion: upcasts.length + 1, + personal: personalMarksOf({ schema, path }), }, ]; }), diff --git a/packages/core/src/kernel/aggregate/runtime.ts b/packages/core/src/kernel/aggregate/runtime.ts index 44c1adb1..9eace293 100644 --- a/packages/core/src/kernel/aggregate/runtime.ts +++ b/packages/core/src/kernel/aggregate/runtime.ts @@ -1,6 +1,7 @@ import type { z } from "zod"; import type { NewEvent, StoredEvent } from "../../contracts/event.ts"; import type { Upcast } from "../../modules/upcast.ts"; +import type { PersonalMarks } from "../personal-data/marks.ts"; export interface EventRuntime { readonly key: string; @@ -18,6 +19,10 @@ export interface EventRuntime { * `upcasts.length + 1`: what new events of this type are written with. */ readonly schemaVersion: number; + /** + * `null` for an event without personal fields. + */ + readonly personal: PersonalMarks | null; } export interface CommandRuntime { diff --git a/packages/core/src/kernel/app.ts b/packages/core/src/kernel/app.ts index c0c6f224..7638eefa 100644 --- a/packages/core/src/kernel/app.ts +++ b/packages/core/src/kernel/app.ts @@ -4,7 +4,7 @@ import { checkConfigNames } from "../config/names.ts"; import { resolveConfig } from "../config/schema.ts"; import type { Config, ResolvedConfig, RuntimeRole } from "../config/types.ts"; import { type Clock, systemClock } from "../contracts/clock.ts"; -import type { CommandRejection, DispatchResult } from "../contracts/command.ts"; +import type { CommandRejection, DispatchResult, StoredDispatch } from "../contracts/command.ts"; import { ConfigurationError } from "../contracts/errors.ts"; import { type IdGenerator, uuidV7IdGenerator } from "../contracts/ids.ts"; import { type Logger, silentLogger } from "../contracts/logger.ts"; @@ -12,13 +12,14 @@ import type { CommandsFacade, QueriesFacade, Registry } from "../modules/registr import { validateRegistry } from "../modules/validate.ts"; import type { AppEnv, AppRegistry, EnvSection } from "../register/index.ts"; import { buildAggregates } from "./aggregate/build-aggregates.ts"; -import { withUpcasting } from "./aggregate/upcasting.ts"; import { createCommandsFacade } from "./command/facade.ts"; import { createCommandPipeline } from "./command/pipeline.ts"; import { createDeadLetters, type DeadLetters } from "./dead-letters/dead-letters.ts"; import { createDispatcher, type DispatcherLag } from "./dispatch/dispatcher.ts"; import { alignReactiveCheckpoints } from "./dispatch/reactive-checkpoints.ts"; +import { appStorage } from "./events/app-event-store.ts"; import { createGlobalStream, type GlobalStream } from "./events/global-stream.ts"; +import { forgetPersonalData } from "./personal-data/forget.ts"; import { buildPolicies } from "./policy/build-policies.ts"; import { createDelayedPolicies } from "./policy/delayed.ts"; import { createPolicyExecutor } from "./policy/executor.ts"; @@ -65,6 +66,15 @@ export interface BoundaClient { * when more remain, which its alarm runs. */ rebuildReadModel(name: string): Promise; + /** + * Forgets a person: deletes the key their personal fields are encrypted with, so every event + * of theirs reads those fields as `null` from now on, and appends `PersonalDataForgotten` to + * the stream `bounda:`, which every read model that projects personal fields handles. + * Both happen together or not at all. Resolves like a command, so + * `catchUpReadModels({ through })` waits for those read models. Rejects with + * `ValidationError` an empty id. + */ + forget(personId: string): Promise; } /** @@ -274,10 +284,7 @@ export const assembleApp: AssembleAppFunction = async ({ const written = await config.storage.createStorage({ logger }); opened.unshift(() => written.close()); const aggregates = buildAggregates({ registry, ports: ports.byAggregate }); - const storage = { - ...written, - eventStore: withUpcasting({ eventStore: written.eventStore, aggregates }), - }; + const storage = appStorage({ storage: written, aggregates }); const readModels = await buildReadModels({ registry, config, logger }); opened.unshift(() => readModels.close()); // One list per `runUntilIdle` under way. @@ -487,6 +494,15 @@ export const assembleApp: AssembleAppFunction = async ({ }, events: createGlobalStream({ eventStore: written.eventStore }), deadLetters, + forget: (personId) => + forgetPersonalData({ + storage, + ids, + clock, + logger, + concurrencyRetries: config.runtime.commands.concurrencyRetries, + personId, + }), getLag: async () => ({ ...(await dispatcher.getLag()), waitingDeadlines: worker.waitingDeadlines(), diff --git a/packages/core/src/kernel/events/app-event-store.ts b/packages/core/src/kernel/events/app-event-store.ts new file mode 100644 index 00000000..180d5cf8 --- /dev/null +++ b/packages/core/src/kernel/events/app-event-store.ts @@ -0,0 +1,41 @@ +import type { Storage } from "../../adapter/adapter.ts"; +import type { AggregatesRuntime } from "../aggregate/runtime.ts"; +import { withUpcasting } from "../aggregate/upcasting.ts"; +import { withPersonalData } from "../personal-data/event-store.ts"; + +export interface AppStorageArgs { + readonly storage: Storage; + readonly aggregates: AggregatesRuntime; +} + +export interface AppStorageFunction { + (args: AppStorageArgs): Storage; +} + +/** + * The storage as the kernel uses it: its event store, and the one of every transaction, seal and + * open personal fields and upcast what they read, so handlers only ever see current, readable + * payloads. The adapter's own `eventStore` stays as written, for the global stream. + */ +export const appStorage: AppStorageFunction = ({ storage, aggregates }) => ({ + ...storage, + eventStore: withUpcasting({ + eventStore: withPersonalData({ + eventStore: storage.eventStore, + aggregates, + keys: storage.personalKeys, + }), + aggregates, + }), + transact: (work) => + storage.transact((transaction) => + work({ + ...transaction, + eventStore: withPersonalData({ + eventStore: transaction.eventStore, + aggregates, + keys: transaction.personalKeys, + }), + }), + ), +}); diff --git a/packages/core/src/kernel/personal-data/crypto.test.ts b/packages/core/src/kernel/personal-data/crypto.test.ts new file mode 100644 index 00000000..169386aa --- /dev/null +++ b/packages/core/src/kernel/personal-data/crypto.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { generatePersonalKey, importPersonalKey, isSealed, seal, unseal } from "./crypto.ts"; + +describe("personal data crypto", () => { + it("seals a value under a person and opens it with the same key, whatever its type", async () => { + const key = await importPersonalKey(await generatePersonalKey()); + for (const value of ["ana@example.com", 42, true, null, { street: "Main 1" }, ["a", "b"]]) { + const sealed = await seal({ key, person: "c-1", value }); + expect(isSealed(sealed)).toBe(true); + expect(sealed.$personal.person).toBe("c-1"); + expect(sealed.$personal.key).toBe(key.id); + expect(sealed.$personal.data).not.toContain(JSON.stringify(value)); + expect(await unseal({ key, sealed })).toEqual(value); + } + }); + + it("gives every key and every sealing a different text", async () => { + const keys = await Promise.all([generatePersonalKey(), generatePersonalKey()]); + expect(keys[0]).not.toBe(keys[1]); + expect(keys[0]).toHaveLength(44); + const key = await importPersonalKey(keys[0] ?? ""); + const [first, second] = await Promise.all([ + seal({ key, person: "c-1", value: "same" }), + seal({ key, person: "c-1", value: "same" }), + ]); + expect(first.$personal.iv).not.toBe(second.$personal.iv); + expect(first.$personal.data).not.toBe(second.$personal.data); + }); + + it("imports a key that cannot be exported again, with a fingerprint of its own", async () => { + const stored = await generatePersonalKey(); + const key = await importPersonalKey(stored); + expect(key.cryptoKey.extractable).toBe(false); + expect(key.cryptoKey.algorithm).toEqual({ name: "AES-GCM", length: 256 }); + expect((await importPersonalKey(stored)).id).toBe(key.id); + expect((await importPersonalKey(await generatePersonalKey())).id).not.toBe(key.id); + expect(key.id).not.toBe(stored); + }); + + it("opens nothing without a key or with another one, and refuses a value its key should open", async () => { + const [own, other] = await Promise.all([generatePersonalKey(), generatePersonalKey()]); + const key = await importPersonalKey(own); + const sealed = await seal({ key, person: "c-1", value: "ana@example.com" }); + expect(await unseal({ key: null, sealed })).toBeNull(); + expect(await unseal({ key: await importPersonalKey(other), sealed })).toBeNull(); + const forged = { $personal: { ...sealed.$personal, data: sealed.$personal.iv } }; + await expect(unseal({ key, sealed: forged })).rejects.toThrow(); + }); + + it("takes for sealed only an object holding nothing but a complete envelope", () => { + const envelope = { person: "c-1", key: "k", iv: "aQ==", data: "ZA==" }; + expect(isSealed({ $personal: envelope })).toBe(true); + expect(isSealed({ $personal: envelope, more: 1 })).toBe(false); + expect(isSealed({ $personal: { ...envelope, data: 1 } })).toBe(false); + expect(isSealed({ $personal: { person: "c-1", iv: "aQ==" } })).toBe(false); + expect(isSealed({ $personal: { ...envelope, person: 1 } })).toBe(false); + expect(isSealed({ $personal: { ...envelope, iv: 1 } })).toBe(false); + expect(isSealed({ $personal: { ...envelope, key: 1 } })).toBe(false); + expect(isSealed({ $personal: null })).toBe(false); + expect(isSealed({ personal: envelope })).toBe(false); + expect(isSealed({ $personal: "text" })).toBe(false); + expect(isSealed([{ $personal: envelope }])).toBe(false); + expect(isSealed("ana@example.com")).toBe(false); + expect(isSealed(null)).toBe(false); + }); +}); diff --git a/packages/core/src/kernel/personal-data/crypto.ts b/packages/core/src/kernel/personal-data/crypto.ts new file mode 100644 index 00000000..2d3c87c3 --- /dev/null +++ b/packages/core/src/kernel/personal-data/crypto.ts @@ -0,0 +1,141 @@ +const ALGORITHM = "AES-GCM"; +const KEY_BITS = 256; +const IV_BYTES = 12; + +/** + * A personal field as it is stored: the ciphertext, its nonce, the person whose key opens it and + * the fingerprint of that key, so a value sealed under a key the person no longer has is told + * from one their current key opens. + */ +export interface PersonalEnvelope { + readonly person: string; + readonly key: string; + readonly iv: string; + readonly data: string; +} + +/** + * A person's key as the store uses it, with the fingerprint envelopes name it by. + */ +export interface PersonalKey { + readonly id: string; + readonly cryptoKey: CryptoKey; +} + +/** + * A stored field value that holds personal data: nothing but the envelope, under a key no schema + * field takes. + */ +export interface SealedValue { + readonly $personal: PersonalEnvelope; +} + +export interface IsSealedFunction { + (value: unknown): value is SealedValue; +} + +export const isSealed: IsSealedFunction = (value): value is SealedValue => { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const keys = Object.keys(value); + const envelope: unknown = Reflect.get(value, "$personal"); + return ( + keys.length === 1 && + keys[0] === "$personal" && + typeof envelope === "object" && + envelope !== null && + typeof Reflect.get(envelope, "person") === "string" && + typeof Reflect.get(envelope, "key") === "string" && + typeof Reflect.get(envelope, "iv") === "string" && + typeof Reflect.get(envelope, "data") === "string" + ); +}; + +const toBase64 = (bytes: Uint8Array): string => { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +}; + +const fromBase64 = (text: string): Uint8Array => + Uint8Array.from(atob(text), (character) => character.charCodeAt(0)); + +export interface GeneratePersonalKeyFunction { + (): Promise; +} + +/** + * A fresh key, as the text the key store keeps. + */ +export const generatePersonalKey: GeneratePersonalKeyFunction = async () => { + const key = await crypto.subtle.generateKey({ name: ALGORITHM, length: KEY_BITS }, true, [ + "encrypt", + "decrypt", + ]); + return toBase64(new Uint8Array(await crypto.subtle.exportKey("raw", key))); +}; + +export interface ImportPersonalKeyFunction { + (key: string): Promise; +} + +export const importPersonalKey: ImportPersonalKeyFunction = async (key) => { + const raw = fromBase64(key); + const [digest, cryptoKey] = await Promise.all([ + crypto.subtle.digest("SHA-256", raw), + crypto.subtle.importKey("raw", raw, ALGORITHM, false, ["encrypt", "decrypt"]), + ]); + return { id: toBase64(new Uint8Array(digest)), cryptoKey }; +}; + +export interface SealArgs { + readonly key: PersonalKey; + readonly person: string; + readonly value: unknown; +} + +export interface SealFunction { + (args: SealArgs): Promise; +} + +/** + * Encrypts `value` as JSON, under a nonce of its own, so equal values never store alike. + */ +export const seal: SealFunction = async ({ key, person, value }) => { + const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES)); + const encrypted = await crypto.subtle.encrypt( + { name: ALGORITHM, iv }, + key.cryptoKey, + new TextEncoder().encode(JSON.stringify(value)), + ); + return { + $personal: { + person, + key: key.id, + iv: toBase64(iv), + data: toBase64(new Uint8Array(encrypted)), + }, + }; +}; + +export interface UnsealArgs { + readonly key: PersonalKey | null; + readonly sealed: SealedValue; +} + +export interface UnsealFunction { + (args: UnsealArgs): Promise; +} + +/** + * `null` without a key, or with one the value was not sealed under: the person was forgotten, + * and may have been given a new key since. A value the key should open and does not throws. + */ +export const unseal: UnsealFunction = async ({ key, sealed: { $personal: envelope } }) => { + if (key === null || key.id !== envelope.key) return null; + const decrypted = await crypto.subtle.decrypt( + { name: ALGORITHM, iv: fromBase64(envelope.iv) }, + key.cryptoKey, + fromBase64(envelope.data), + ); + return JSON.parse(new TextDecoder().decode(decrypted)) as unknown; +}; diff --git a/packages/core/src/kernel/personal-data/event-store.test.ts b/packages/core/src/kernel/personal-data/event-store.test.ts new file mode 100644 index 00000000..ef96630c --- /dev/null +++ b/packages/core/src/kernel/personal-data/event-store.test.ts @@ -0,0 +1,309 @@ +import { describe, expect, it } from "vitest"; +import type { PersonalKeyStore } from "../../adapter/storage/personal-key-store.ts"; +import { pendingEvent } from "../../adapter/testing/fixtures.ts"; +import { ValidationError } from "../../contracts/errors.ts"; +import { silentLogger } from "../../contracts/logger.ts"; +import { memory } from "../../memory/index.ts"; +import type { EventPayloadArgs } from "../../modules/payload.ts"; +import type { Registry } from "../../modules/registry.ts"; +import { buildAggregates } from "../aggregate/build-aggregates.ts"; +import { isSealed } from "./crypto.ts"; +import { withPersonalData } from "./event-store.ts"; + +const registry: Registry = { + aggregates: { + customer: { + events: { + customerRegistered: { + payload: ({ z, personal }: EventPayloadArgs) => + z.object({ email: personal(z.email()), plan: z.string() }), + evolve: () => ({}), + }, + customerRenamed: { evolve: () => ({}) }, + }, + commands: {}, + policies: {}, + processes: {}, + }, + order: { + events: { + orderPlaced: { + payload: ({ z, personal, person }: EventPayloadArgs) => + z.object({ + customerId: person(z.string()), + note: personal(z.string()).optional(), + address: personal(z.object({ street: z.string() })), + total: z.number(), + }), + evolve: () => ({}), + }, + }, + commands: {}, + policies: {}, + processes: {}, + }, + }, + readModels: {}, +}; + +const setup = async () => { + const storage = await memory().createStorage({ logger: silentLogger }); + const calls: string[] = []; + const keys: PersonalKeyStore = { + get: async (person) => { + calls.push(`get:${person}`); + return storage.personalKeys.get(person); + }, + add: async (person, key) => { + calls.push(`add:${person}`); + return storage.personalKeys.add(person, key); + }, + remove: (person) => storage.personalKeys.remove(person), + }; + const store = withPersonalData({ + eventStore: storage.eventStore, + aggregates: buildAggregates({ registry, ports: {} }), + keys, + }); + const registered = (version: number, email: string) => + pendingEvent({ + aggregateType: "customer", + aggregateId: "c-1", + version, + type: "CustomerRegistered", + payload: { email, plan: "pro" }, + }); + const placed = (payload: object, aggregateId = "o-1") => + pendingEvent({ aggregateType: "order", aggregateId, version: 1, type: "OrderPlaced", payload }); + return { storage, store, calls, registered, placed }; +}; + +describe("withPersonalData", () => { + it("stores the personal fields sealed under the aggregate's id and reads them back in the clear", async () => { + const { storage, store, registered } = await setup(); + const appended = await store.append({ + aggregateType: "customer", + aggregateId: "c-1", + expectedVersion: 0, + events: [registered(1, "ana@example.com")], + }); + expect(appended.events[0]?.payload).toEqual({ email: "ana@example.com", plan: "pro" }); + const [stored] = await storage.eventStore.readAll({ afterPosition: 0, limit: 10 }); + const payload = stored?.payload as { email: unknown; plan: string }; + expect(payload.plan).toBe("pro"); + expect(isSealed(payload.email)).toBe(true); + expect(JSON.stringify(stored)).not.toContain("ana@example.com"); + expect((payload.email as { $personal: { person: string } }).$personal.person).toBe("c-1"); + expect(await storage.personalKeys.get("c-1")).toBeTypeOf("string"); + const loaded = await store.load({ aggregateType: "customer", aggregateId: "c-1" }); + expect(loaded.events[0]?.payload).toEqual({ email: "ana@example.com", plan: "pro" }); + expect((await store.readAll({ afterPosition: 0, limit: 10 }))[0]?.payload).toEqual({ + email: "ana@example.com", + plan: "pro", + }); + }); + + it("seals under the person the payload names, leaves an absent optional field out, and seals an object whole", async () => { + const { storage, store, placed } = await setup(); + await store.append({ + aggregateType: "order", + aggregateId: "o-1", + expectedVersion: 0, + events: [placed({ customerId: "c-7", address: { street: "Main 1" }, total: 10 })], + }); + const [stored] = await storage.eventStore.readAll({ afterPosition: 0, limit: 10 }); + const payload = stored?.payload as Record; + expect(Object.keys(payload)).toEqual(["customerId", "address", "total"]); + expect(payload.customerId).toBe("c-7"); + expect(isSealed(payload.address)).toBe(true); + expect((payload.address as { $personal: { person: string } }).$personal.person).toBe("c-7"); + expect(await storage.personalKeys.get("c-7")).toBeTypeOf("string"); + expect(await storage.personalKeys.get("o-1")).toBeNull(); + const loaded = await store.load({ aggregateType: "order", aggregateId: "o-1" }); + expect(loaded.events[0]?.payload).toEqual({ + customerId: "c-7", + address: { street: "Main 1" }, + total: 10, + }); + }); + + it("reads a forgotten person's fields as null and keeps the rest", async () => { + const { storage, store, registered, placed } = await setup(); + await store.append({ + aggregateType: "customer", + aggregateId: "c-1", + expectedVersion: 0, + events: [registered(1, "ana@example.com")], + }); + await store.append({ + aggregateType: "order", + aggregateId: "o-1", + expectedVersion: 0, + events: [ + placed({ customerId: "c-1", note: "ring twice", address: { street: "x" }, total: 1 }), + ], + }); + await storage.personalKeys.remove("c-1"); + const all = await store.readAll({ afterPosition: 0, limit: 10 }); + expect(all.map((event) => event.payload)).toEqual([ + { email: null, plan: "pro" }, + { customerId: "c-1", note: null, address: null, total: 1 }, + ]); + }); + + it("reads what was sealed under a key the person no longer has as null, next to what their new key opens", async () => { + const { storage, store, registered } = await setup(); + await store.append({ + aggregateType: "customer", + aggregateId: "c-1", + expectedVersion: 0, + events: [registered(1, "ana@example.com")], + }); + await storage.personalKeys.remove("c-1"); + await store.append({ + aggregateType: "customer", + aggregateId: "c-1", + expectedVersion: 1, + events: [registered(2, "ana@again.example")], + }); + const loaded = await store.load({ aggregateType: "customer", aggregateId: "c-1" }); + expect(loaded.events.map((event) => (event.payload as { email: unknown }).email)).toEqual([ + null, + "ana@again.example", + ]); + }); + + it("reuses a person's key across events and appends, and fetches it once per call", async () => { + const { storage, store, calls, registered } = await setup(); + await store.append({ + aggregateType: "customer", + aggregateId: "c-1", + expectedVersion: 0, + events: [registered(1, "ana@example.com"), registered(2, "ana@other.example")], + }); + expect(calls).toEqual(["get:c-1", "add:c-1"]); + await store.append({ + aggregateType: "customer", + aggregateId: "c-1", + expectedVersion: 2, + events: [registered(3, "ana@third.example")], + }); + expect(calls).toEqual(["get:c-1", "add:c-1", "get:c-1"]); + calls.length = 0; + const loaded = await store.load({ aggregateType: "customer", aggregateId: "c-1" }); + expect(loaded.events.map((event) => (event.payload as { email: string }).email)).toEqual([ + "ana@example.com", + "ana@other.example", + "ana@third.example", + ]); + expect(calls).toEqual(["get:c-1"]); + const persons = new Set( + (await storage.eventStore.readAll({ afterPosition: 0, limit: 10 })).map( + (event) => + (event.payload as { email: { $personal: { person: string } } }).email.$personal.person, + ), + ); + expect(persons).toEqual(new Set(["c-1"])); + }); + + it("keeps the key another writer added first", async () => { + const { storage, store, registered } = await setup(); + await storage.personalKeys.add("c-1", "Zmlyc3Q="); + const concurrentKeys: PersonalKeyStore = { + get: async () => null, + add: (person, key) => storage.personalKeys.add(person, key), + remove: (person) => storage.personalKeys.remove(person), + }; + await expect( + withPersonalData({ + eventStore: storage.eventStore, + aggregates: buildAggregates({ registry, ports: {} }), + keys: concurrentKeys, + }).append({ + aggregateType: "customer", + aggregateId: "c-1", + expectedVersion: 0, + events: [registered(1, "ana@example.com")], + }), + ).rejects.toThrow(); + expect(await storage.personalKeys.get("c-1")).toBe("Zmlyc3Q="); + void store; + }); + + it("leaves events without personal fields, and of aggregates it does not know, as they are", async () => { + const { storage, store, calls } = await setup(); + const plain = [ + pendingEvent({ + aggregateType: "customer", + aggregateId: "c-1", + version: 1, + type: "CustomerRenamed", + payload: { name: "Ana" }, + }), + ]; + await store.append({ + aggregateType: "customer", + aggregateId: "c-1", + expectedVersion: 0, + events: plain, + }); + await store.append({ + aggregateType: "process:x", + aggregateId: "p-1", + expectedVersion: 0, + events: [ + pendingEvent({ + aggregateType: "process:x", + aggregateId: "p-1", + version: 1, + type: "ProcessStarted", + payload: "text", + }), + ], + }); + expect(calls).toEqual([]); + const all = await store.readAll({ afterPosition: 0, limit: 10 }); + expect(all.map((event) => event.payload)).toEqual([{ name: "Ana" }, "text"]); + expect(await storage.eventStore.readAll({ afterPosition: 0, limit: 10 })).toEqual(all); + expect(await store.lastPosition()).toBe(2); + }); + + it("refuses an event whose person field is empty", async () => { + const { store, placed } = await setup(); + await expect( + store.append({ + aggregateType: "order", + aggregateId: "o-1", + expectedVersion: 0, + events: [placed({ customerId: "", address: { street: "x" }, total: 1 })], + }), + ).rejects.toThrow( + new ValidationError('Event OrderPlaced of order:o-1 names no person in "customerId"', [ + { path: ["customerId"], message: "Expected a non-empty string" }, + ]), + ); + }); + + it("refuses an event whose person field is not a string or a number", async () => { + const { store, placed } = await setup(); + await expect( + store.append({ + aggregateType: "order", + aggregateId: "o-1", + expectedVersion: 0, + events: [placed({ customerId: { id: "c-1" }, address: { street: "x" }, total: 1 })], + }), + ).rejects.toBeInstanceOf(ValidationError); + }); + + it("takes a number for a person's id", async () => { + const { storage, store, placed } = await setup(); + await store.append({ + aggregateType: "order", + aggregateId: "o-1", + expectedVersion: 0, + events: [placed({ customerId: 42, address: { street: "x" }, total: 1 })], + }); + expect(await storage.personalKeys.get("42")).toBeTypeOf("string"); + }); +}); diff --git a/packages/core/src/kernel/personal-data/event-store.ts b/packages/core/src/kernel/personal-data/event-store.ts new file mode 100644 index 00000000..52e43899 --- /dev/null +++ b/packages/core/src/kernel/personal-data/event-store.ts @@ -0,0 +1,136 @@ +import type { EventStore, PendingEvent } from "../../adapter/storage/event-store.ts"; +import type { PersonalKeyStore } from "../../adapter/storage/personal-key-store.ts"; +import { ValidationError } from "../../contracts/errors.ts"; +import type { StoredEvent } from "../../contracts/event.ts"; +import type { AggregatesRuntime } from "../aggregate/runtime.ts"; +import { isRecord } from "../shared/merge-fields.ts"; +import { + generatePersonalKey, + importPersonalKey, + isSealed, + type PersonalKey, + type SealedValue, + seal, + unseal, +} from "./crypto.ts"; +import type { PersonalMarks } from "./marks.ts"; + +export interface WithPersonalDataArgs { + readonly eventStore: EventStore; + readonly aggregates: AggregatesRuntime; + readonly keys: PersonalKeyStore; +} + +export interface WithPersonalDataFunction { + (args: WithPersonalDataArgs): EventStore; +} + +const personOf = (event: PendingEvent, payload: Record, field: string): string => { + const value = payload[field]; + if ((typeof value !== "string" && typeof value !== "number") || value === "") { + throw new ValidationError( + `Event ${event.type} of ${event.aggregateType}:${event.aggregateId} names no person in "${field}"`, + [{ path: [field], message: "Expected a non-empty string" }], + ); + } + return String(value); +}; + +/** + * The keys one call needs, each fetched once: a load reads many events of one person, and an + * append may write several. Never kept past the call, so a key removed meanwhile is never added + * back from here. + */ +const keyCache = () => { + const keys = new Map>(); + return (person: string, open: () => Promise): Promise => { + const cached = keys.get(person); + if (cached !== undefined) return cached; + const opened = open(); + keys.set(person, opened); + return opened; + }; +}; + +/** + * The event store handlers write to and read from: personal fields go in encrypted with the key + * of the person the event names, created on their first event, and come out decrypted, or as + * `null` once the person's key is gone. Below the upcasts, which see the fields as written. + */ +export const withPersonalData: WithPersonalDataFunction = ({ eventStore, aggregates, keys }) => { + const marksOf = (event: Pick): PersonalMarks | null => + aggregates.byName[event.aggregateType]?.eventsByType[event.type]?.personal ?? null; + + const keyInForce = async (person: string): Promise => { + const stored = + (await keys.get(person)) ?? (await keys.add(person, await generatePersonalKey())); + return importPersonalKey(stored); + }; + + const sealed = async ( + event: PendingEvent, + key: (person: string) => Promise, + ): Promise => { + const marks = marksOf(event); + if (marks === null || !isRecord(event.payload)) return event; + const person = + marks.person === null ? event.aggregateId : personOf(event, event.payload, marks.person); + const payload: Record = { ...event.payload }; + for (const field of marks.fields) { + const value = payload[field]; + if (value === undefined) continue; + payload[field] = await seal({ key: await key(person), person, value }); + } + return { ...event, payload }; + }; + + const unsealed = async ( + event: StoredEvent, + key: (person: string) => Promise, + ): Promise => { + if (!isRecord(event.payload)) return event; + const entries = Object.entries(event.payload).filter(([, value]) => isSealed(value)); + if (entries.length === 0) return event; + const payload: Record = { ...event.payload }; + for (const [field, value] of entries as [string, SealedValue][]) { + payload[field] = await unseal({ key: await key(value.$personal.person), sealed: value }); + } + return { ...event, payload }; + }; + + const opening = (): ((person: string) => Promise) => { + const cache = keyCache(); + return (person) => + cache(person, async () => { + const stored = await keys.get(person); + return stored === null ? null : importPersonalKey(stored); + }); + }; + + return { + // The result carries the events as given, since the store holds them sealed. + append: async (args) => { + const cache = keyCache(); + const key = (person: string) => cache(person, () => keyInForce(person)); + const events = await Promise.all(args.events.map((event) => sealed(event, key))); + const appended = await eventStore.append({ ...args, events }); + return { + ...appended, + events: appended.events.map((event, index) => ({ + ...event, + payload: args.events[index]?.payload, + })), + }; + }, + load: async (args) => { + const loaded = await eventStore.load(args); + const key = opening(); + return { ...loaded, events: await Promise.all(loaded.events.map((e) => unsealed(e, key))) }; + }, + readAll: async (args) => { + const key = opening(); + return Promise.all((await eventStore.readAll(args)).map((e) => unsealed(e, key))); + }, + lastPosition: () => eventStore.lastPosition(), + }; +}; diff --git a/packages/core/src/kernel/personal-data/forget.test.ts b/packages/core/src/kernel/personal-data/forget.test.ts new file mode 100644 index 00000000..4fd072c8 --- /dev/null +++ b/packages/core/src/kernel/personal-data/forget.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import type { Storage } from "../../adapter/adapter.ts"; +import { createFixedClock } from "../../contracts/clock.ts"; +import { ConcurrencyError, ValidationError } from "../../contracts/errors.ts"; +import { createSequentialIdGenerator } from "../../contracts/ids.ts"; +import { silentLogger } from "../../contracts/logger.ts"; +import { memory } from "../../memory/index.ts"; +import { createRecordingLogger } from "../test-support.ts"; +import { forgetPersonalData } from "./forget.ts"; + +const setup = async (storage?: Storage) => { + const live = storage ?? (await memory().createStorage({ logger: silentLogger })); + const { logger, entries: logs } = createRecordingLogger(); + const forget = (personId: string) => + forgetPersonalData({ + storage: live, + ids: createSequentialIdGenerator(), + clock: createFixedClock(), + logger, + concurrencyRetries: 2, + personId, + }); + return { storage: live, forget, logs }; +}; + +describe("forgetPersonalData", () => { + it("removes the person's key and appends PersonalDataForgotten to their stream, answering like a command", async () => { + const { storage, forget, logs } = await setup(); + await storage.personalKeys.add("c-1", "a2V5"); + expect(await forget("c-1")).toEqual({ + scheduled: false, + aggregateType: "bounda", + aggregateId: "c-1", + version: 1, + eventIds: ["id-2"], + eventTypes: ["PersonalDataForgotten"], + position: 1, + }); + expect(await storage.personalKeys.get("c-1")).toBeNull(); + expect(await storage.eventStore.readAll({ afterPosition: 0, limit: 10 })).toEqual([ + { + id: "id-2", + aggregateType: "bounda", + aggregateId: "c-1", + version: 1, + position: 1, + type: "PersonalDataForgotten", + payload: { personId: "c-1" }, + timestamp: "2026-01-01T00:00:00.000Z", + metadata: { + correlationId: "id-1", + causationId: "id-1", + depth: 0, + schemaVersion: 1, + system: true, + }, + }, + ]); + expect(logs).toContainEqual({ + level: "info", + message: "personal data forgotten", + fields: { personId: "c-1", hadKey: true }, + }); + }); + + it("forgets a person without a key too, once more on every call", async () => { + const { storage, forget, logs } = await setup(); + await forget("c-2"); + expect((await forget("c-2")).version).toBe(2); + expect(await storage.eventStore.lastPosition()).toBe(2); + expect(logs.map((entry) => entry.fields)).toEqual([ + { personId: "c-2", hadKey: false }, + { personId: "c-2", hadKey: false }, + ]); + }); + + it("refuses an empty id", async () => { + const { storage, forget } = await setup(); + await expect(forget("")).rejects.toThrow( + new ValidationError("forget needs the id of the person to forget", [ + { path: ["personId"], message: "Expected a non-empty string" }, + ]), + ); + await expect(forget(undefined as unknown as string)).rejects.toBeInstanceOf(ValidationError); + expect(await storage.eventStore.lastPosition()).toBe(0); + }); + + it("runs again when the person's stream moved under it, and gives up past the retries", async () => { + const base = await memory().createStorage({ logger: silentLogger }); + let conflicts = 0; + const racing: Storage = { + ...base, + transact: (work) => + base.transact(async (transaction) => { + const result = await work(transaction); + if (conflicts > 0) { + conflicts -= 1; + throw new ConcurrencyError({ + streamId: "bounda:c-1", + expectedVersion: 0, + actualVersion: 1, + }); + } + return result; + }), + }; + const { forget } = await setup(racing); + conflicts = 2; + expect((await forget("c-1")).version).toBe(1); + conflicts = 3; + await expect(forget("c-1")).rejects.toBeInstanceOf(ConcurrencyError); + }); +}); diff --git a/packages/core/src/kernel/personal-data/forget.ts b/packages/core/src/kernel/personal-data/forget.ts new file mode 100644 index 00000000..d4dcb24f --- /dev/null +++ b/packages/core/src/kernel/personal-data/forget.ts @@ -0,0 +1,83 @@ +import type { Storage } from "../../adapter/adapter.ts"; +import type { Clock } from "../../contracts/clock.ts"; +import type { StoredDispatch } from "../../contracts/command.ts"; +import { ConcurrencyError, ValidationError } from "../../contracts/errors.ts"; +import type { IdGenerator } from "../../contracts/ids.ts"; +import type { Logger } from "../../contracts/logger.ts"; +import { + BOUNDA_AGGREGATE, + PERSONAL_DATA_FORGOTTEN_EVENT, + type PersonalDataForgottenPayload, +} from "../../modules/bounda-events.ts"; +import { appendSystemEvent } from "../system-events.ts"; + +export interface ForgetPersonalDataArgs { + readonly storage: Storage; + readonly ids: IdGenerator; + readonly clock: Clock; + readonly logger: Logger; + readonly concurrencyRetries: number; + readonly personId: string; +} + +export interface ForgetPersonalDataFunction { + (args: ForgetPersonalDataArgs): Promise; +} + +/** + * Removes the person's key and appends `PersonalDataForgotten` to their stream, in one + * transaction: whoever reads the event finds the fields gone already. A person without a key is + * forgotten all the same, since read models may hold what their events carried in the clear. + */ +export const forgetPersonalData: ForgetPersonalDataFunction = async ({ + storage, + ids, + clock, + logger, + concurrencyRetries, + personId, +}) => { + if (typeof personId !== "string" || personId === "") { + throw new ValidationError("forget needs the id of the person to forget", [ + { path: ["personId"], message: "Expected a non-empty string" }, + ]); + } + const correlationId = ids.next(); + const payload: PersonalDataForgottenPayload = { personId }; + for (let attempt = 0; ; attempt += 1) { + try { + const { removed, stored } = await storage.transact(async (transaction) => ({ + removed: await transaction.personalKeys.remove(personId), + stored: await appendSystemEvent({ + eventStore: transaction.eventStore, + ids, + clock, + aggregateType: BOUNDA_AGGREGATE, + aggregateId: personId, + type: PERSONAL_DATA_FORGOTTEN_EVENT, + payload, + context: { correlationId, causationId: correlationId, depth: 0 }, + }), + })); + logger.info("personal data forgotten", { personId, hadKey: removed }); + // Read once committed: an adapter that stages a transaction's events has no position for + // them until then, and the position is what a read-your-writes catch-up waits for. + const { events } = await storage.eventStore.load({ + aggregateType: BOUNDA_AGGREGATE, + aggregateId: personId, + fromVersion: stored.version, + }); + return { + scheduled: false, + aggregateType: BOUNDA_AGGREGATE, + aggregateId: personId, + version: stored.version, + eventIds: [stored.id], + eventTypes: [stored.type], + position: events[0]?.position ?? stored.position, + }; + } catch (error) { + if (!(error instanceof ConcurrencyError) || attempt >= concurrencyRetries) throw error; + } + } +}; diff --git a/packages/core/src/kernel/personal-data/marks.test.ts b/packages/core/src/kernel/personal-data/marks.test.ts new file mode 100644 index 00000000..d9250744 --- /dev/null +++ b/packages/core/src/kernel/personal-data/marks.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { ConfigurationError } from "../../contracts/errors.ts"; +import { person, personal } from "../../modules/personal.ts"; +import { personalMarksOf } from "./marks.ts"; + +const path = "aggregates.order.events.orderPlaced"; + +describe("personalMarksOf", () => { + it("is null for an event without a payload or without personal fields", () => { + expect(personalMarksOf({ schema: null, path })).toBeNull(); + expect(personalMarksOf({ schema: z.object({ total: z.number() }), path })).toBeNull(); + expect(personalMarksOf({ schema: z.string(), path })).toBeNull(); + }); + + it("lists the personal fields, with the person the aggregate's id names when none is marked", () => { + const schema = z.object({ + total: z.number(), + email: personal(z.email()), + address: personal(z.object({ street: z.string() })).optional(), + phone: personal(z.string()).nullable().default(null), + name: personal(z.string()).transform((value) => value?.trim()), + }); + expect(personalMarksOf({ schema, path })).toEqual({ + person: null, + fields: ["email", "address", "phone", "name"], + }); + }); + + it("names the person field, however it is wrapped", () => { + const schema = z.object({ + customerId: person(z.uuid()).readonly(), + note: personal(z.string()), + }); + expect(personalMarksOf({ schema, path })).toEqual({ person: "customerId", fields: ["note"] }); + }); + + it("finds no mark deeper than the top level of the payload", () => { + const schema = z.object({ contact: z.object({ email: personal(z.email()) }) }); + expect(personalMarksOf({ schema, path })).toBeNull(); + }); + + it("is null for a person without personal fields, which nothing encrypts", () => { + const schema = z.object({ customerId: person(z.string()), total: z.number() }); + expect(personalMarksOf({ schema, path })).toBeNull(); + }); + + it("refuses two person fields", () => { + const schema = z.object({ + buyerId: person(z.string()), + sellerId: person(z.string()), + email: personal(z.email()), + }); + expect(() => personalMarksOf({ schema, path })).toThrow( + new ConfigurationError( + `${path}: marks "buyerId" and "sellerId" with person; an event names at most one person`, + ), + ); + }); +}); diff --git a/packages/core/src/kernel/personal-data/marks.ts b/packages/core/src/kernel/personal-data/marks.ts new file mode 100644 index 00000000..60a07ffc --- /dev/null +++ b/packages/core/src/kernel/personal-data/marks.ts @@ -0,0 +1,44 @@ +import { z } from "zod"; +import { ConfigurationError } from "../../contracts/errors.ts"; +import { personalMarkOf } from "../../modules/personal.ts"; + +/** + * What an event's payload schema marks: the fields to encrypt and the field naming the person + * they belong to, `null` when the aggregate's id names them. + */ +export interface PersonalMarks { + readonly person: string | null; + readonly fields: readonly string[]; +} + +export interface PersonalMarksOfArgs { + readonly schema: z.ZodType | null; + readonly path: string; +} + +export interface PersonalMarksOfFunction { + (args: PersonalMarksOfArgs): PersonalMarks | null; +} + +/** + * `null` for an event without personal fields. Only the top-level fields of an object payload + * are read: a mark deeper down, or on a payload that is not an object, is never found, so a + * whole object or array is marked as one field. + */ +export const personalMarksOf: PersonalMarksOfFunction = ({ schema, path }) => { + if (!(schema instanceof z.ZodObject)) return null; + const fields: string[] = []; + const persons: string[] = []; + for (const [name, field] of Object.entries(schema.shape)) { + const mark = personalMarkOf(field as z.ZodType); + if (mark === "personal") fields.push(name); + if (mark === "person") persons.push(name); + } + if (persons.length > 1) { + throw new ConfigurationError( + `${path}: marks ${persons.map((name) => `"${name}"`).join(" and ")} with person; an event names at most one person`, + ); + } + if (fields.length === 0) return null; + return { person: persons[0] ?? null, fields }; +}; diff --git a/packages/core/src/kernel/personal-data/personal-data.test.ts b/packages/core/src/kernel/personal-data/personal-data.test.ts new file mode 100644 index 00000000..52c8c3b1 --- /dev/null +++ b/packages/core/src/kernel/personal-data/personal-data.test.ts @@ -0,0 +1,273 @@ +import { describe, expect, it } from "vitest"; +import type { Table } from "../../adapter/storage/table.ts"; +import { ConfigurationError, ValidationError } from "../../contracts/errors.ts"; +import type { StoredEvent } from "../../contracts/event.ts"; +import { silentLogger } from "../../contracts/logger.ts"; +import { memory } from "../../memory/index.ts"; +import type { PersonalDataForgottenEvent } from "../../modules/bounda-events.ts"; +import type { EventPayloadArgs, PayloadArgs } from "../../modules/payload.ts"; +import type { Registry } from "../../modules/registry.ts"; +import type { FieldsArgs } from "../../modules/view.ts"; +import { createTestApp } from "../../testing/index.ts"; +import { createApp } from "../app.ts"; +import { isSealed } from "./crypto.ts"; + +interface CustomerState { + readonly email: string | null; + readonly name: string; +} + +interface Row { + readonly customerId: string; + readonly email?: string; + readonly name: string; +} + +const erased: string[] = []; + +const registry = { + aggregates: { + customer: { + state: { initialState: { email: null, name: "" } satisfies CustomerState }, + events: { + customerRegistered: { + payload: ({ z, personal }: EventPayloadArgs) => + z.object({ email: personal(z.email()), name: z.string() }), + evolve: ({ event }: { event: { payload: { email: string | null; name: string } } }) => ({ + email: event.payload.email, + name: event.payload.name, + }), + }, + customerGreeted: { + payload: ({ z }: EventPayloadArgs) => z.object({ to: z.string().nullable() }), + evolve: ({ state }: { state: CustomerState }) => state, + }, + }, + commands: { + registerCustomer: { + module: { + payload: ({ z }: PayloadArgs) => + z.object({ customerId: z.string(), email: z.email(), name: z.string() }), + handler: ({ + command, + events, + }: { + command: { payload: { email: string; name: string } }; + events: Record unknown>; + }) => [events.customerRegistered?.(command.payload)], + }, + }, + greetCustomer: { + module: { + payload: ({ z }: PayloadArgs) => z.object({ customerId: z.string() }), + handler: ({ + state, + events, + }: { + state: CustomerState; + events: Record unknown>; + }) => [events.customerGreeted?.({ to: state.email })], + }, + }, + }, + policies: { + boundaEraseOnPersonalDataForgotten: { + module: { + handler: async ({ event }: { event: PersonalDataForgottenEvent }) => { + erased.push(event.payload.personId); + }, + }, + source: "bounda", + }, + }, + processes: {}, + }, + }, + readModels: { + customerDirectory: { + view: { + fields: ({ f }: FieldsArgs) => ({ + customerId: f.string().primaryKey(), + email: f.string().optional(), + name: f.string(), + }), + }, + projections: { + customer: { + customerRegistered: { + project: async ({ + event, + table, + }: { + event: StoredEvent<"CustomerRegistered", { email: string | null; name: string }>; + table: Table; + }) => { + await table.upsert({ + customerId: event.aggregateId, + name: event.payload.name, + ...(event.payload.email === null ? {} : { email: event.payload.email }), + }); + }, + }, + }, + bounda: { + personalDataForgotten: { + project: async ({ + event, + table, + }: { + event: PersonalDataForgottenEvent; + table: Table; + }) => { + await table.delete({ customerId: event.payload.personId }); + }, + }, + }, + }, + queries: { + getCustomer: { + payload: ({ z }: PayloadArgs) => z.object({ customerId: z.string() }), + repository: ({ table, customerId }: { table: Table; customerId: string }) => + table.findOne({ customerId }), + handler: ({ repositoryData }: { repositoryData: Row | null }) => repositoryData, + }, + }, + }, + }, +} as const satisfies Registry; + +const ana = { customerId: "c-1", email: "ana@example.com", name: "Ana" }; + +describe("personal data in an app", () => { + it("stores personal fields sealed, projects them in the clear, and forgets a person end to end", async () => { + erased.length = 0; + const { app } = await createTestApp({ registry }); + const registered = await app.commands.registerCustomer(ana); + await app.catchUpReadModels({ through: registered }); + expect(await app.queries.getCustomer({ customerId: "c-1" })).toEqual({ + customerId: "c-1", + email: "ana@example.com", + name: "Ana", + }); + const [raw] = await app.events.read(); + expect(isSealed((raw?.payload as { email: unknown } | undefined)?.email)).toBe(true); + expect(JSON.stringify(raw)).not.toContain("ana@example.com"); + + const forgotten = await app.forget("c-1"); + expect(forgotten).toMatchObject({ + aggregateType: "bounda", + aggregateId: "c-1", + eventTypes: ["PersonalDataForgotten"], + position: 2, + }); + await app.catchUpReadModels({ through: forgotten }); + expect(await app.queries.getCustomer({ customerId: "c-1" })).toBeNull(); + expect(JSON.stringify(await app.events.read())).not.toContain("ana@example.com"); + + const greeted = await app.commands.greetCustomer({ customerId: "c-1" }); + expect(greeted.eventTypes).toEqual(["CustomerGreeted"]); + const [, , greeting] = await app.events.read(); + expect(greeting?.payload).toEqual({ to: null }); + + expect((await app.runUntilIdle()).idle).toBe(true); + expect(erased).toEqual(["c-1"]); + await app.stop(); + }); + + it("rebuilds a read model from the sealed stream, reading what is still readable", async () => { + const { app } = await createTestApp({ registry }); + await app.commands.registerCustomer(ana); + await app.commands.registerCustomer({ customerId: "c-2", email: "bo@example.com", name: "Bo" }); + await app.forget("c-1"); + await app.runUntilIdle(); + expect(await app.rebuildReadModel("customerDirectory")).toMatchObject({ done: true }); + expect(await app.queries.getCustomer({ customerId: "c-1" })).toBeNull(); + expect(await app.queries.getCustomer({ customerId: "c-2" })).toEqual({ + customerId: "c-2", + email: "bo@example.com", + name: "Bo", + }); + await app.stop(); + }); + + it("keeps the keys of other people and takes a new one for someone registered again", async () => { + const { app } = await createTestApp({ registry }); + await app.commands.registerCustomer(ana); + await app.commands.registerCustomer({ customerId: "c-2", email: "bo@example.com", name: "Bo" }); + await app.forget("c-1"); + await app.commands.greetCustomer({ customerId: "c-2" }); + await app.commands.registerCustomer({ ...ana, email: "ana@again.example" }); + await app.commands.greetCustomer({ customerId: "c-1" }); + const events = await app.events.read(); + expect(events.map((event) => event.type)).toEqual([ + "CustomerRegistered", + "CustomerRegistered", + "PersonalDataForgotten", + "CustomerGreeted", + "CustomerRegistered", + "CustomerGreeted", + ]); + expect(events[3]?.payload).toEqual({ to: "bo@example.com" }); + expect(events[5]?.payload).toEqual({ to: "ana@again.example" }); + await app.catchUpReadModels(); + expect(await app.queries.getCustomer({ customerId: "c-1" })).toEqual({ + customerId: "c-1", + email: "ana@again.example", + name: "Ana", + }); + await app.stop(); + }); + + it("refuses to forget nobody", async () => { + const { app } = await createTestApp({ registry }); + await expect(app.forget("")).rejects.toBeInstanceOf(ValidationError); + await app.stop(); + }); + + it("refuses a policy on an event the runtime does not emit, and an aggregate named bounda", async () => { + await expect( + createApp({ + registry: { + ...registry, + aggregates: { + ...registry.aggregates, + bounda: { events: {}, commands: {}, policies: {}, processes: {} }, + }, + }, + config: { storage: memory() }, + logger: silentLogger, + }), + ).rejects.toThrow( + new ConfigurationError( + [ + "Invalid registry:", + ' aggregates.bounda: "bounda" is the runtime\'s own aggregate; give yours another name', + ].join("\n"), + ), + ); + await expect( + createApp({ + registry: { + ...registry, + aggregates: { + customer: { + ...registry.aggregates.customer, + policies: { + boundaNotifyOnPersonalDataLost: { + module: { on: "PersonalDataLost", handler: async () => undefined }, + source: "bounda", + }, + }, + }, + }, + }, + config: { storage: memory() }, + logger: silentLogger, + }), + ).rejects.toThrow( + new ConfigurationError( + 'aggregates.customer.policies.boundaNotifyOnPersonalDataLost: "PersonalDataLost" is not an event of the aggregate "bounda"', + ), + ); + }); +}); diff --git a/packages/core/src/kernel/policy/build-policies.ts b/packages/core/src/kernel/policy/build-policies.ts index 5c9501de..c54e512d 100644 --- a/packages/core/src/kernel/policy/build-policies.ts +++ b/packages/core/src/kernel/policy/build-policies.ts @@ -1,8 +1,10 @@ import { parseDuration } from "../../contracts/duration.ts"; import { ConfigurationError } from "../../contracts/errors.ts"; -import { capitalize, policyTrigger } from "../../modules/naming.ts"; +import { BOUNDA_AGGREGATE } from "../../modules/bounda-events.ts"; +import { policyTrigger } from "../../modules/naming.ts"; import type { PolicyModule } from "../../modules/policy.ts"; import type { Registry } from "../../modules/registry.ts"; +import { eventTypesOf } from "../../modules/validate.ts"; import type { AggregatesRuntime } from "../aggregate/runtime.ts"; import { qualifiedEventType } from "../shared/qualified-event.ts"; @@ -59,13 +61,12 @@ const triggersOf = ( source: string, ): readonly string[] => { const path = `aggregates.${aggregate}.policies.${key}`; - const events = registry.aggregates[source]?.events; - if (events === undefined) { + if (source !== BOUNDA_AGGREGATE && registry.aggregates[source] === undefined) { throw new ConfigurationError( `${path}: there is no aggregate "${source}" whose events to react to`, ); } - const types = Object.keys(events).map(capitalize); + const types = eventTypesOf(registry.aggregates, source); const known = new Set(types); const triggers = declaredTriggers(path, key, module, source, types); for (const trigger of triggers) { diff --git a/packages/core/src/kernel/read-model/rebuild.ts b/packages/core/src/kernel/read-model/rebuild.ts index 5f1f832b..f8b739c4 100644 --- a/packages/core/src/kernel/read-model/rebuild.ts +++ b/packages/core/src/kernel/read-model/rebuild.ts @@ -10,8 +10,8 @@ import type { Registry } from "../../modules/registry.ts"; import { validateRegistry } from "../../modules/validate.ts"; import { fieldBuilder } from "../../modules/view.ts"; import { buildAggregates } from "../aggregate/build-aggregates.ts"; -import { withUpcasting } from "../aggregate/upcasting.ts"; import { PartialBatchError } from "../dispatch/delivery.ts"; +import { appStorage } from "../events/app-event-store.ts"; import { projectBatch, projectionSubscriberName } from "../projection/runner.ts"; import { guardedLogger } from "../shared/guarded-logger.ts"; import { adapterForReadModel, compileProjections } from "./build-read-models.ts"; @@ -115,9 +115,9 @@ export const rebuildReadModel: RebuildReadModelFunction = async ({ } const storage = await config.storage.createStorage({ logger }); try { - const eventStore = withUpcasting({ - eventStore: storage.eventStore, - // A rebuild runs no handler, so it builds no implementation. + // A rebuild runs no handler, so it builds no implementation. + const { eventStore } = appStorage({ + storage, aggregates: buildAggregates({ registry, ports: {} }), }); const progress = `${progressPrefix(name)}${fingerprintReadModel(entry)}`; diff --git a/packages/core/src/kernel/unit-of-work/unit-of-work.ts b/packages/core/src/kernel/unit-of-work/unit-of-work.ts index a8014c3e..3286f06d 100644 --- a/packages/core/src/kernel/unit-of-work/unit-of-work.ts +++ b/packages/core/src/kernel/unit-of-work/unit-of-work.ts @@ -18,7 +18,7 @@ export type UnitStores = Pick { commit(): Promise; /** * Runs `callback` once the unit has committed, once however often it commits; never for a unit diff --git a/packages/core/src/memory/index.ts b/packages/core/src/memory/index.ts index 210b6b22..71956b34 100644 --- a/packages/core/src/memory/index.ts +++ b/packages/core/src/memory/index.ts @@ -16,6 +16,7 @@ import { createKeptDeadLetterStore } from "./dead-letter-store.ts"; import { createMemoryEventNotifier } from "./event-notifier.ts"; import { createMemoryEventStore } from "./event-store.ts"; import { createKeptInboxLedger } from "./inbox-ledger.ts"; +import { createKeptPersonalKeyStore } from "./personal-key-store.ts"; import { createKeptScheduler } from "./scheduler.ts"; import { createMemoryReadClient, createMemoryTable, type MemoryTable } from "./table.ts"; import { @@ -100,11 +101,13 @@ export const memory: MemoryFunction = (options = {}) => { const inboxLedger = createKeptInboxLedger(); const deadLetterStore = createKeptDeadLetterStore(); const scheduler = createKeptScheduler(); + const personalKeys = createKeptPersonalKeyStore(); const stores = { eventStore: createMemoryEventStore({ onAppend: notifier.notify }), inboxLedger: inboxLedger.store, deadLetterStore: deadLetterStore.store, scheduler: scheduler.store, + personalKeys: personalKeys.store, }; storage ??= { ...stores, @@ -116,6 +119,7 @@ export const memory: MemoryFunction = (options = {}) => { inboxLedger: inboxLedger.entries, deadLetterStore: deadLetterStore.entries, scheduler: scheduler.entries, + personalKeys: personalKeys.entries, }, }), close: async () => {}, diff --git a/packages/core/src/memory/memory.test.ts b/packages/core/src/memory/memory.test.ts index 79d0045f..c3994182 100644 --- a/packages/core/src/memory/memory.test.ts +++ b/packages/core/src/memory/memory.test.ts @@ -8,6 +8,7 @@ import { eventStoreContract, inboxLedgerContract, pendingEvent, + personalKeyStoreContract, readModelRebuildContract, readModelTransactionContract, schedulerContract, @@ -30,6 +31,7 @@ describe("memory adapter", () => { deadLetterStoreContract({ create: async () => (await storage()).deadLetterStore }); schedulerContract({ create: async () => (await storage()).scheduler }); storageTransactionContract({ create: storage }); + personalKeyStoreContract({ create: async () => (await storage()).personalKeys }); tableContract({ create: async () => { const model = await memory().createReadModel({ diff --git a/packages/core/src/memory/personal-key-store.ts b/packages/core/src/memory/personal-key-store.ts new file mode 100644 index 00000000..a70e5ebb --- /dev/null +++ b/packages/core/src/memory/personal-key-store.ts @@ -0,0 +1,30 @@ +import type { PersonalKeyStore } from "../adapter/storage/personal-key-store.ts"; +import { createStoreEntries, type WithEntries } from "./entries.ts"; + +export interface CreateKeptPersonalKeyStoreFunction { + (): WithEntries; +} + +export const createKeptPersonalKeyStore: CreateKeptPersonalKeyStoreFunction = () => { + const keys = new Map(); + return { + store: { + get: async (person) => keys.get(person) ?? null, + add: async (person, key) => { + const existing = keys.get(person); + if (existing !== undefined) return existing; + keys.set(person, key); + return key; + }, + remove: async (person) => keys.delete(person), + }, + entries: createStoreEntries(keys, (person) => person), + }; +}; + +export interface CreateMemoryPersonalKeyStoreFunction { + (): PersonalKeyStore; +} + +export const createMemoryPersonalKeyStore: CreateMemoryPersonalKeyStoreFunction = () => + createKeptPersonalKeyStore().store; diff --git a/packages/core/src/memory/transaction.ts b/packages/core/src/memory/transaction.ts index ce7ed133..2e827553 100644 --- a/packages/core/src/memory/transaction.ts +++ b/packages/core/src/memory/transaction.ts @@ -4,6 +4,7 @@ import { createStagedEventStore } from "../adapter/staged-event-store.ts"; import type { CheckpointStore } from "../adapter/storage/checkpoint-store.ts"; import type { DeadLetterStore } from "../adapter/storage/dead-letter-store.ts"; import type { ClaimKey, InboxLedger } from "../adapter/storage/inbox-ledger.ts"; +import type { PersonalKeyStore } from "../adapter/storage/personal-key-store.ts"; import type { Scheduler } from "../adapter/storage/scheduler.ts"; import type { StoreEntries } from "./entries.ts"; import type { MemoryEventStore } from "./event-store.ts"; @@ -13,10 +14,12 @@ export interface CreateMemoryStorageTransactionArgs { readonly inboxLedger: InboxLedger; readonly deadLetterStore: DeadLetterStore; readonly scheduler: Scheduler; + readonly personalKeys: PersonalKeyStore; readonly entries: { readonly inboxLedger: StoreEntries; readonly deadLetterStore: StoreEntries; readonly scheduler: StoreEntries; + readonly personalKeys: StoreEntries; }; } @@ -24,21 +27,18 @@ export interface CreateMemoryStorageTransactionFunction { (args: CreateMemoryStorageTransactionArgs): Storage["transact"]; } -type CreateUndoLogArgs = Omit; +type CreateUndoLogArgs = Omit; interface UndoLog { readonly target: DeferredWriteTarget; undo(): void; } -const createUndoLog = ({ - inboxLedger, - deadLetterStore, - scheduler, - entries, -}: CreateUndoLogArgs): UndoLog => { - const undos: (() => void)[] = []; - const tracked = ( +type Undos = (() => void)[]; + +const trackedBy = + (undos: Undos) => + ( store: StoreEntries, key: NoInfer, write: () => Promise, @@ -51,6 +51,34 @@ const createUndoLog = ({ undos.push(() => store.putBack(key, before, after)); return written; }; + +const undoAll = (undos: Undos): void => { + for (const undo of undos.splice(0).reverse()) undo(); +}; + +// The keys are written at once, since `add` answers with the key in force, and put back when the +// transaction fails. +const trackedPersonalKeys = ( + personalKeys: PersonalKeyStore, + entries: StoreEntries, + undos: Undos, +): PersonalKeyStore => { + const tracked = trackedBy(undos); + return { + get: (person) => personalKeys.get(person), + add: (person, key) => tracked(entries, person, () => personalKeys.add(person, key)), + remove: (person) => tracked(entries, person, () => personalKeys.remove(person)), + }; +}; + +const createUndoLog = ({ + inboxLedger, + deadLetterStore, + scheduler, + entries, +}: CreateUndoLogArgs): UndoLog => { + const undos: Undos = []; + const tracked = trackedBy(undos); return { target: { inboxLedger: { @@ -77,9 +105,7 @@ const createUndoLog = ({ tracked(entries.scheduler, args.claim.dedupeKey, () => scheduler.defer(args)), }, }, - undo: () => { - for (const undo of undos.splice(0).reverse()) undo(); - }, + undo: () => undoAll(undos), }; }; @@ -92,13 +118,25 @@ const createUndoLog = ({ */ export const createMemoryStorageTransaction: CreateMemoryStorageTransactionFunction = ({ eventStore, + personalKeys, ...live }) => { const commits = createMemoryLocks(); return async (work) => { const staged = createStagedEventStore(eventStore); const { stores, flush } = deferWrites(live); - const result = await work({ eventStore: staged, ...stores }); + const keyUndos: Undos = []; + let result: Awaited>; + try { + result = await work({ + eventStore: staged, + ...stores, + personalKeys: trackedPersonalKeys(personalKeys, live.entries.personalKeys, keyUndos), + }); + } catch (error) { + undoAll(keyUndos); + throw error; + } // A transaction committed from inside this commit, as from a store write, waits forever. const release = await commits.acquire("commit", true); const log = createUndoLog(live); @@ -107,6 +145,7 @@ export const createMemoryStorageTransaction: CreateMemoryStorageTransactionFunct await eventStore.appendAll(staged.batches()); } catch (error) { log.undo(); + undoAll(keyUndos); throw error; } finally { release?.(); diff --git a/packages/core/src/modules/bounda-events.ts b/packages/core/src/modules/bounda-events.ts new file mode 100644 index 00000000..fc96b928 --- /dev/null +++ b/packages/core/src/modules/bounda-events.ts @@ -0,0 +1,31 @@ +import type { StoredEvent } from "../contracts/event.ts"; + +/** + * The aggregate the runtime's own events belong to, one stream per subject: a read model + * projects them from `projections/bounda/.ts`, and a policy reacts to them from + * `policies/bounda/-on-.ts`. No aggregate of the app can take the name. + */ +export const BOUNDA_AGGREGATE: "bounda" = "bounda"; + +/** + * Appended by `forget(personId)` to the stream `bounda:`, once the person's key is + * gone: every event's personal field of theirs reads as `null` from then on, and every read model + * that projects such fields handles it. + */ +export const PERSONAL_DATA_FORGOTTEN_EVENT: "PersonalDataForgotten" = "PersonalDataForgotten"; + +export interface PersonalDataForgottenPayload { + readonly personId: string; +} + +export type PersonalDataForgottenEvent = StoredEvent< + typeof PERSONAL_DATA_FORGOTTEN_EVENT, + PersonalDataForgottenPayload +>; + +/** + * The runtime's events, by the key a module's file name gives them. + */ +export const BOUNDA_EVENTS: Readonly> = { + personalDataForgotten: PERSONAL_DATA_FORGOTTEN_EVENT, +}; diff --git a/packages/core/src/modules/event.ts b/packages/core/src/modules/event.ts index cfa0d1d7..b2265608 100644 --- a/packages/core/src/modules/event.ts +++ b/packages/core/src/modules/event.ts @@ -1,10 +1,10 @@ import type { NewEvent, StoredEvent } from "../contracts/event.ts"; import type { TypeNameOf } from "./naming.ts"; import { capitalize } from "./naming.ts"; -import type { HasPayload, PayloadFunction, PayloadInputOf, PayloadOf } from "./payload.ts"; +import type { EventPayloadFunction, HasPayload, PayloadInputOf, PayloadOf } from "./payload.ts"; export interface EventModule { - readonly payload?: PayloadFunction; + readonly payload?: EventPayloadFunction; readonly begin?: (args: never) => object; readonly evolve?: (args: never) => object; } diff --git a/packages/core/src/modules/index.ts b/packages/core/src/modules/index.ts index dcd3e3f5..85846050 100644 --- a/packages/core/src/modules/index.ts +++ b/packages/core/src/modules/index.ts @@ -5,6 +5,15 @@ export type { StateOf, UnknownState, } from "./aggregate.ts"; +export type { + PersonalDataForgottenEvent, + PersonalDataForgottenPayload, +} from "./bounda-events.ts"; +export { + BOUNDA_AGGREGATE, + BOUNDA_EVENTS, + PERSONAL_DATA_FORGOTTEN_EVENT, +} from "./bounda-events.ts"; export type { CommandHandlerArgs, CommandRejectionsArgs, @@ -30,11 +39,20 @@ export type { export { capitalize, policyTrigger, toCamelCase } from "./naming.ts"; export type { EmptyPayload, + EventPayloadArgs, PayloadArgs, PayloadInputOf, PayloadOf, ZodApi, } from "./payload.ts"; +export type { + ForgetsPersonalData, + HasPersonalFields, + PersonalFunction, + PersonalSchema, + PersonFunction, + PersonSchema, +} from "./personal.ts"; export type { PolicyHandlerArgs, PolicyModule } from "./policy.ts"; export type { CreateArgs, diff --git a/packages/core/src/modules/payload.ts b/packages/core/src/modules/payload.ts index 8cc0082a..0d16e3dc 100644 --- a/packages/core/src/modules/payload.ts +++ b/packages/core/src/modules/payload.ts @@ -1,4 +1,5 @@ import type { z } from "zod"; +import type { PersonalFunction, PersonFunction } from "./personal.ts"; /** * The Zod namespace, injected into every `payload`, `state` and similar schema function so user @@ -15,18 +16,31 @@ export interface PayloadArgs { export type PayloadFunction = (args: PayloadArgs) => Schema; +/** + * Arguments of an event's `payload`: `z`, and `personal` and `person` to mark the fields that + * hold personal data and the one that names whose it is. + */ +export interface EventPayloadArgs extends PayloadArgs { + readonly personal: PersonalFunction; + readonly person: PersonFunction; +} + +export type EventPayloadFunction = ( + args: EventPayloadArgs, +) => Schema; + /** * The payload type of a module without `payload`: an object with no keys. */ export type EmptyPayload = Record; -export type InferPayload = F extends (args: PayloadArgs) => infer Schema +export type InferPayload = F extends (args: never) => infer Schema ? Schema extends z.ZodType ? z.output : never : never; -export type InferPayloadInput = F extends (args: PayloadArgs) => infer Schema +export type InferPayloadInput = F extends (args: never) => infer Schema ? Schema extends z.ZodType ? z.input : never @@ -48,6 +62,6 @@ export type PayloadInputOf = Module extends { readonly payload: infer F ? InferPayloadInput : EmptyPayload; -export type HasPayload = Module extends { readonly payload: (args: PayloadArgs) => unknown } +export type HasPayload = Module extends { readonly payload: (args: never) => unknown } ? true : false; diff --git a/packages/core/src/modules/personal.test.ts b/packages/core/src/modules/personal.test.ts new file mode 100644 index 00000000..dfae68d6 --- /dev/null +++ b/packages/core/src/modules/personal.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { person, personal, personalMarkOf } from "./personal.ts"; + +describe("personal and person", () => { + it("validate as the schema they wrap and hand the value through", () => { + const schema = z.object({ + customerId: person(z.string().min(1)), + email: personal(z.email()), + note: personal(z.string()).optional(), + }); + expect(schema.parse({ customerId: "c-1", email: "ana@example.com" })).toEqual({ + customerId: "c-1", + email: "ana@example.com", + }); + expect(schema.safeParse({ customerId: "", email: "ana@example.com" }).success).toBe(false); + expect(schema.safeParse({ customerId: "c-1", email: "ana" }).success).toBe(false); + }); + + it("mark the schema they return, found through any wrapper and never on the one they took", () => { + const email = z.email(); + expect(personalMarkOf(personal(email))).toBe("personal"); + expect(personalMarkOf(personal(email).optional().nullable())).toBe("personal"); + expect(personalMarkOf(person(z.string()))).toBe("person"); + expect(personalMarkOf(person(z.string()).default("x").readonly())).toBe("person"); + expect(personalMarkOf(personal(email).transform((value) => value?.toLowerCase()))).toBe( + "personal", + ); + expect(personalMarkOf(z.string().pipe(z.email()))).toBeNull(); + expect(personalMarkOf(email)).toBeNull(); + expect(personalMarkOf(z.object({ email: personal(email) }))).toBeNull(); + }); +}); diff --git a/packages/core/src/modules/personal.ts b/packages/core/src/modules/personal.ts new file mode 100644 index 00000000..7f736824 --- /dev/null +++ b/packages/core/src/modules/personal.ts @@ -0,0 +1,127 @@ +import type { z } from "zod"; + +declare const personalBrand: unique symbol; +declare const personBrand: unique symbol; + +/** + * The schema `personal(schema)` returns: what `schema` takes when the event is written, and what + * it gives or `null` when read, since the field reads as `null` once its person is forgotten. + */ +export interface PersonalSchema + extends z.ZodPipe | null, z.output>> { + readonly [personalBrand]: true; +} + +/** + * The schema `person(schema)` returns: `schema`, marked as the field that identifies the person + * the event's personal fields belong to. + */ +export interface PersonSchema + extends z.ZodPipe, z.output>> { + readonly [personBrand]: true; +} + +/** + * Marks a field of an event's payload as personal data: it is stored encrypted with the key of + * the person the event names, and reads as `null` once that person is forgotten. Any schema + * works, an object or an array included, at the top level of the payload. A command or a query + * payload cannot use it. + */ +export interface PersonalFunction { + (schema: Schema): PersonalSchema; +} + +/** + * Marks the field of an event's payload that identifies the person its personal fields belong + * to, such as the customer's id in an order's event. Without it, the aggregate's id is the + * person. An event has at most one. + */ +export interface PersonFunction { + (schema: Schema): PersonSchema; +} + +const PERSONAL = new WeakSet(); +const PERSON = new WeakSet(); + +export const personal: PersonalFunction = (schema: Schema) => { + const marked = schema.transform((value): z.output | null => value); + PERSONAL.add(marked); + return marked as PersonalSchema; +}; + +export const person: PersonFunction = (schema: Schema) => { + const marked = schema.transform((value): z.output => value); + PERSON.add(marked); + return marked as PersonSchema; +}; + +export type PersonalMark = "personal" | "person"; + +export interface PersonalMarkOfFunction { + (schema: z.ZodType): PersonalMark | null; +} + +interface Wrapper { + readonly innerType?: z.ZodType; + readonly in?: z.ZodType; +} + +// A mark sits on the schema `personal` or `person` returned, however it was wrapped afterwards: +// `.optional()`, `.nullable()`, `.default()` and the like keep what they wrap in `innerType`, +// and a `.transform()` or `.pipe()` in `in`. +export const personalMarkOf: PersonalMarkOfFunction = (schema) => { + let current: z.ZodType | undefined = schema; + while (current !== undefined) { + if (PERSONAL.has(current)) return "personal"; + if (PERSON.has(current)) return "person"; + const def = current._zod.def as Wrapper; + current = def.innerType ?? def.in; + } + return null; +}; + +type Unwrapped = Schema extends { + readonly _zod: { readonly def: { readonly innerType: infer Inner } }; +} + ? Inner + : Schema extends { readonly _zod: { readonly def: { readonly in: infer Inner } } } + ? Inner + : never; + +type IsPersonal = Schema extends { readonly [personalBrand]: true } + ? true + : IsPersonalInside>; + +// Apart so a schema without `innerType` ends the recursion instead of instantiating it on `never`. +type IsPersonalInside = [Inner] extends [never] ? false : IsPersonal; + +type SchemaOf = Module extends { readonly payload: (args: never) => infer Schema } + ? Schema + : never; + +/** + * Whether an event module marks any top-level field of its payload with `personal`. Distributes + * over a union of modules. + */ +export type HasPersonalFields = Module extends unknown + ? SchemaOf extends z.ZodObject + ? true extends { [Key in keyof Shape]: IsPersonal }[keyof Shape] + ? true + : false + : false + : never; + +/** + * `true` when the read model `Name` may project `Modules`: when it projects + * `PersonalDataForgotten` (`Handles`), or when none of them has a personal field. Otherwise a + * message saying what to add, which the generator's `.bounda/types.ts` fails to compile on. + */ +export type ForgetsPersonalData< + Name extends string, + Modules, + Handles extends boolean, +> = Handles extends true + ? true + : true extends HasPersonalFields + ? `Read model ${Name} projects personal data, so it needs projections/bounda/personal-data-forgotten.ts` + : true; diff --git a/packages/core/src/modules/validate.test.ts b/packages/core/src/modules/validate.test.ts index a54fcc4b..9781a3fa 100644 --- a/packages/core/src/modules/validate.test.ts +++ b/packages/core/src/modules/validate.test.ts @@ -294,6 +294,34 @@ describe("validateRegistry", () => { ); }); + it("takes projections of the runtime's own events, and only of those", () => { + const projecting = (projections: Registry["readModels"][string]["projections"]): Registry => ({ + aggregates: { order }, + readModels: { orderSummary: { ...orderSummary, projections } }, + }); + expect(() => + validateRegistry(projecting({ bounda: { personalDataForgotten: { project: noop } } })), + ).not.toThrow(); + expect(() => + validateRegistry( + projecting({ + bounda: { + personalDataLost: { project: noop }, + byOn: { on: "OrderPlaced", project: noop }, + }, + }), + ), + ).toThrow( + new ConfigurationError( + [ + "Invalid registry:", + ' readModels.orderSummary.projections.bounda.personalDataLost: "PersonalDataLost" is not an event of the aggregate "bounda"; name the file after one or export "on"', + ' readModels.orderSummary.projections.bounda.byOn: "OrderPlaced" is not an event of the aggregate "bounda"; name the file after one or export "on"', + ].join("\n"), + ), + ); + }); + it("rejects a process named like a policy of its aggregate", () => { const registry = withOrder({ policies: { ...order.policies, orderPayment: { module: { handler: noop } } }, diff --git a/packages/core/src/modules/validate.ts b/packages/core/src/modules/validate.ts index 50609baa..1e9da59e 100644 --- a/packages/core/src/modules/validate.ts +++ b/packages/core/src/modules/validate.ts @@ -1,4 +1,5 @@ import { ConfigurationError } from "../contracts/errors.ts"; +import { BOUNDA_AGGREGATE, BOUNDA_EVENTS } from "./bounda-events.ts"; import { capitalize } from "./naming.ts"; import type { PortModules } from "./port.ts"; import { projectionTriggers } from "./projection.ts"; @@ -66,6 +67,12 @@ const validateAggregate = ( aggregate: Registry["aggregates"][string], ): void => { const base = `aggregates.${name}`; + if (name === BOUNDA_AGGREGATE) { + problems.push({ + path: base, + message: `"${BOUNDA_AGGREGATE}" is the runtime's own aggregate; give yours another name`, + }); + } if (aggregate.state !== undefined && !isRecord(aggregate.state.initialState)) { problems.push({ path: `${base}.state`, message: 'export "initialState" must be an object' }); } @@ -130,6 +137,18 @@ const validateAggregate = ( } }; +export interface EventTypesOfFunction { + (aggregates: Registry["aggregates"], aggregate: string): readonly string[]; +} + +/** + * The event types an aggregate of the registry, or the runtime's own, emits. + */ +export const eventTypesOf: EventTypesOfFunction = (aggregates, aggregate) => + aggregate === BOUNDA_AGGREGATE + ? Object.values(BOUNDA_EVENTS) + : Object.keys(aggregates[aggregate]?.events ?? {}).map(capitalize); + const validateReadModel = ( problems: Problem[], name: string, @@ -139,19 +158,18 @@ const validateReadModel = ( const base = `readModels.${name}`; requireFunction(problems, readModel.view, `${base}.view`, "fields"); for (const [aggregate, projections] of Object.entries(readModel.projections)) { - if (!(aggregate in aggregates)) { + const known = aggregate === BOUNDA_AGGREGATE || aggregate in aggregates; + if (!known) { problems.push({ path: `${base}.projections.${aggregate}`, message: `there is no aggregate "${aggregate}" whose events to project`, }); } - const events = new Set( - Object.keys(aggregates[aggregate]?.events ?? {}).map(capitalize), - ); + const events = new Set(eventTypesOf(aggregates, aggregate)); for (const [key, projection] of Object.entries(projections)) { const path = `${base}.projections.${aggregate}.${key}`; requireFunction(problems, projection, path, "project"); - if (!(aggregate in aggregates)) continue; + if (!known) continue; for (const trigger of projectionTriggers(key, projection).filter( (type) => !events.has(type), )) { diff --git a/packages/core/test-types/fixtures/order-app-inferred/app/domain/order/+types/order-cancelled.ts b/packages/core/test-types/fixtures/order-app-inferred/app/domain/order/+types/order-cancelled.ts index 09d31a75..7e5dc2f5 100644 --- a/packages/core/test-types/fixtures/order-app-inferred/app/domain/order/+types/order-cancelled.ts +++ b/packages/core/test-types/fixtures/order-app-inferred/app/domain/order/+types/order-cancelled.ts @@ -4,7 +4,7 @@ import type * as generated from "../../../../.bounda/types.ts"; type Module = typeof import("../order-cancelled.ts"); export declare namespace Event { - type PayloadArgs = core.PayloadArgs; + type PayloadArgs = core.EventPayloadArgs; type BeginArgs = core.EventBeginArgs< "OrderCancelled", core.PayloadOf diff --git a/packages/core/test-types/fixtures/order-app-inferred/app/domain/order/+types/order-paid.ts b/packages/core/test-types/fixtures/order-app-inferred/app/domain/order/+types/order-paid.ts index e2035427..6b7b1c97 100644 --- a/packages/core/test-types/fixtures/order-app-inferred/app/domain/order/+types/order-paid.ts +++ b/packages/core/test-types/fixtures/order-app-inferred/app/domain/order/+types/order-paid.ts @@ -4,7 +4,7 @@ import type * as generated from "../../../../.bounda/types.ts"; type Module = typeof import("../order-paid.ts"); export declare namespace Event { - type PayloadArgs = core.PayloadArgs; + type PayloadArgs = core.EventPayloadArgs; type BeginArgs = core.EventBeginArgs< "OrderPaid", core.PayloadOf diff --git a/packages/core/test-types/fixtures/order-app-inferred/app/domain/order/+types/order-placed.ts b/packages/core/test-types/fixtures/order-app-inferred/app/domain/order/+types/order-placed.ts index af7666ef..2c381c2f 100644 --- a/packages/core/test-types/fixtures/order-app-inferred/app/domain/order/+types/order-placed.ts +++ b/packages/core/test-types/fixtures/order-app-inferred/app/domain/order/+types/order-placed.ts @@ -4,7 +4,7 @@ import type * as generated from "../../../../.bounda/types.ts"; type Module = typeof import("../order-placed.ts"); export declare namespace Event { - type PayloadArgs = core.PayloadArgs; + type PayloadArgs = core.EventPayloadArgs; type BeginArgs = core.EventBeginArgs< "OrderPlaced", core.PayloadOf diff --git a/packages/core/test-types/fixtures/order-app-inferred/app/domain/order/+types/reminder-sent.ts b/packages/core/test-types/fixtures/order-app-inferred/app/domain/order/+types/reminder-sent.ts index 28263159..7ceb3c37 100644 --- a/packages/core/test-types/fixtures/order-app-inferred/app/domain/order/+types/reminder-sent.ts +++ b/packages/core/test-types/fixtures/order-app-inferred/app/domain/order/+types/reminder-sent.ts @@ -4,7 +4,7 @@ import type * as generated from "../../../../.bounda/types.ts"; type Module = typeof import("../reminder-sent.ts"); export declare namespace Event { - type PayloadArgs = core.PayloadArgs; + type PayloadArgs = core.EventPayloadArgs; type BeginArgs = core.EventBeginArgs< "ReminderSent", core.PayloadOf diff --git a/packages/core/test-types/fixtures/order-app/.bounda/registry.ts b/packages/core/test-types/fixtures/order-app/.bounda/registry.ts index 2e13e85b..b7192700 100644 --- a/packages/core/test-types/fixtures/order-app/.bounda/registry.ts +++ b/packages/core/test-types/fixtures/order-app/.bounda/registry.ts @@ -18,6 +18,7 @@ import * as orderCancelled from "../app/domain/order/order-cancelled.ts"; import * as orderPaid from "../app/domain/order/order-paid.ts"; import * as orderPlaced from "../app/domain/order/order-placed.ts"; import * as orderPlacedUpcasts from "../app/domain/order/order-placed.upcast.ts"; +import * as boundaAuditOnPersonalDataForgotten from "../app/domain/order/policies/bounda/audit-on-personal-data-forgotten.ts"; import * as customerGreetOnCustomerRegistered from "../app/domain/order/policies/customer/greet-on-customer-registered.ts"; import * as notifyOnOrderPlaced from "../app/domain/order/policies/notify-on-order-placed.ts"; import * as sendReceiptOnOrderPaid from "../app/domain/order/policies/send-receipt-on-order-paid.ts"; @@ -28,7 +29,12 @@ import * as orderPayment from "../app/domain/order/processes/order-payment/index import * as orderPaymentOnOrderPaid from "../app/domain/order/processes/order-payment/on-order-paid.ts"; import type * as orderReminders from "../app/domain/order/reminders.ts"; import * as orderState from "../app/domain/order/state.ts"; +import * as customerDirectoryOnBoundaPersonalDataForgotten from "../app/read/customer-directory/projections/bounda/personal-data-forgotten.ts"; +import * as customerDirectoryOnCustomerCustomerRegistered from "../app/read/customer-directory/projections/customer/customer-registered.ts"; +import * as getCustomer from "../app/read/customer-directory/queries/get-customer.ts"; +import * as customerDirectoryView from "../app/read/customer-directory/view.ts"; import * as orderSummaryRatesFixed from "../app/read/order-summary/infrastructure/rates/fixed.ts"; +import * as orderSummaryOnBoundaPersonalDataForgotten from "../app/read/order-summary/projections/bounda/personal-data-forgotten.ts"; import * as orderSummaryOnOrderOrderPaid from "../app/read/order-summary/projections/order/order-paid.ts"; import * as orderSummaryOnOrderOrderPlaced from "../app/read/order-summary/projections/order/order-placed.ts"; import * as customerOverview from "../app/read/order-summary/queries/customer-overview.ts"; @@ -67,7 +73,7 @@ export const registry = { }, }, commands: { cancelOrder: { module: cancelOrder }, payOrder: { module: payOrder }, placeOrder: { module: placeOrder } }, - policies: { customerGreetOnCustomerRegistered: { module: customerGreetOnCustomerRegistered, source: "customer" }, notifyOnOrderPlaced: { module: notifyOnOrderPlaced }, sendReceiptOnOrderPaid: { module: sendReceiptOnOrderPaid } }, + policies: { boundaAuditOnPersonalDataForgotten: { module: boundaAuditOnPersonalDataForgotten, source: "bounda" }, customerGreetOnCustomerRegistered: { module: customerGreetOnCustomerRegistered, source: "customer" }, notifyOnOrderPlaced: { module: notifyOnOrderPlaced }, sendReceiptOnOrderPaid: { module: sendReceiptOnOrderPaid } }, processes: { orderPayment: { module: orderPayment, @@ -78,6 +84,11 @@ export const registry = { }, }, readModels: { + customerDirectory: { + view: customerDirectoryView, + projections: { bounda: { personalDataForgotten: customerDirectoryOnBoundaPersonalDataForgotten }, customer: { customerRegistered: customerDirectoryOnCustomerCustomerRegistered } }, + queries: { getCustomer }, + }, orderSummary: { view: orderSummaryView, ports: { @@ -85,7 +96,7 @@ export const registry = { fixed: orderSummaryRatesFixed satisfies ImplementationModule, }, }, - projections: { order: { orderPaid: orderSummaryOnOrderOrderPaid, orderPlaced: orderSummaryOnOrderOrderPlaced } }, + projections: { bounda: { personalDataForgotten: orderSummaryOnBoundaPersonalDataForgotten }, order: { orderPaid: orderSummaryOnOrderOrderPaid, orderPlaced: orderSummaryOnOrderOrderPlaced } }, queries: { customerOverview, getOrder, listUnpaidOrders }, }, }, diff --git a/packages/core/test-types/fixtures/order-app/.bounda/types.ts b/packages/core/test-types/fixtures/order-app/.bounda/types.ts index 7f429f49..3dc49b61 100644 --- a/packages/core/test-types/fixtures/order-app/.bounda/types.ts +++ b/packages/core/test-types/fixtures/order-app/.bounda/types.ts @@ -64,12 +64,33 @@ export type ReactionCommands = core.ReactionCommandsFacadeOf<{ readonly placeOrder: typeof import("../app/domain/order/commands/place-order.ts"); }>; +type Expect = Check; + +export type CustomerDirectoryRow = core.RowOf; +export type CustomerDirectoryPorts = core.EmptyPayload; +export type CustomerDirectoryForgetting = Expect< + core.ForgetsPersonalData< + "customerDirectory", + typeof import("../app/domain/customer/customer-registered.ts"), + true + > +>; + export type OrderSummaryRow = core.RowOf; export type OrderSummaryPorts = { readonly rates: import("../app/read/order-summary/rates.ts").Rates; }; +export type OrderSummaryForgetting = Expect< + core.ForgetsPersonalData< + "orderSummary", + | typeof import("../app/domain/order/order-paid.ts") + | typeof import("../app/domain/order/order-placed.ts"), + true + > +>; export type Queries = core.QueriesFacadeOf<{ + readonly getCustomer: typeof import("../app/read/customer-directory/queries/get-customer.ts"); readonly customerOverview: typeof import("../app/read/order-summary/queries/customer-overview.ts"); readonly getOrder: typeof import("../app/read/order-summary/queries/get-order.ts"); readonly listUnpaidOrders: typeof import("../app/read/order-summary/queries/list-unpaid-orders.ts"); diff --git a/packages/core/test-types/fixtures/order-app/app/domain/customer/+types/customer-registered.ts b/packages/core/test-types/fixtures/order-app/app/domain/customer/+types/customer-registered.ts index 4212b678..1d376ece 100644 --- a/packages/core/test-types/fixtures/order-app/app/domain/customer/+types/customer-registered.ts +++ b/packages/core/test-types/fixtures/order-app/app/domain/customer/+types/customer-registered.ts @@ -4,7 +4,7 @@ import type * as generated from "../../../../.bounda/types.ts"; type Module = typeof import("../customer-registered.ts"); export declare namespace Event { - type PayloadArgs = core.PayloadArgs; + type PayloadArgs = core.EventPayloadArgs; type BeginArgs = core.EventBeginArgs< "CustomerRegistered", core.PayloadOf diff --git a/packages/core/test-types/fixtures/order-app/app/domain/customer/customer-registered.ts b/packages/core/test-types/fixtures/order-app/app/domain/customer/customer-registered.ts index c04dcd8c..f39f9803 100644 --- a/packages/core/test-types/fixtures/order-app/app/domain/customer/customer-registered.ts +++ b/packages/core/test-types/fixtures/order-app/app/domain/customer/customer-registered.ts @@ -1,6 +1,7 @@ import type { Event } from "./+types/customer-registered"; -export const payload = ({ z }: Event.PayloadArgs) => z.object({ email: z.email() }); +export const payload = ({ z, personal }: Event.PayloadArgs) => + z.object({ email: personal(z.email()) }); export const evolve = ({ state, event }: Event.EvolveArgs) => ({ ...state, diff --git a/packages/core/test-types/fixtures/order-app/app/domain/order/+types/order-cancelled.ts b/packages/core/test-types/fixtures/order-app/app/domain/order/+types/order-cancelled.ts index 09d31a75..7e5dc2f5 100644 --- a/packages/core/test-types/fixtures/order-app/app/domain/order/+types/order-cancelled.ts +++ b/packages/core/test-types/fixtures/order-app/app/domain/order/+types/order-cancelled.ts @@ -4,7 +4,7 @@ import type * as generated from "../../../../.bounda/types.ts"; type Module = typeof import("../order-cancelled.ts"); export declare namespace Event { - type PayloadArgs = core.PayloadArgs; + type PayloadArgs = core.EventPayloadArgs; type BeginArgs = core.EventBeginArgs< "OrderCancelled", core.PayloadOf diff --git a/packages/core/test-types/fixtures/order-app/app/domain/order/+types/order-paid.ts b/packages/core/test-types/fixtures/order-app/app/domain/order/+types/order-paid.ts index e2035427..6b7b1c97 100644 --- a/packages/core/test-types/fixtures/order-app/app/domain/order/+types/order-paid.ts +++ b/packages/core/test-types/fixtures/order-app/app/domain/order/+types/order-paid.ts @@ -4,7 +4,7 @@ import type * as generated from "../../../../.bounda/types.ts"; type Module = typeof import("../order-paid.ts"); export declare namespace Event { - type PayloadArgs = core.PayloadArgs; + type PayloadArgs = core.EventPayloadArgs; type BeginArgs = core.EventBeginArgs< "OrderPaid", core.PayloadOf diff --git a/packages/core/test-types/fixtures/order-app/app/domain/order/+types/order-placed.ts b/packages/core/test-types/fixtures/order-app/app/domain/order/+types/order-placed.ts index af7666ef..2c381c2f 100644 --- a/packages/core/test-types/fixtures/order-app/app/domain/order/+types/order-placed.ts +++ b/packages/core/test-types/fixtures/order-app/app/domain/order/+types/order-placed.ts @@ -4,7 +4,7 @@ import type * as generated from "../../../../.bounda/types.ts"; type Module = typeof import("../order-placed.ts"); export declare namespace Event { - type PayloadArgs = core.PayloadArgs; + type PayloadArgs = core.EventPayloadArgs; type BeginArgs = core.EventBeginArgs< "OrderPlaced", core.PayloadOf diff --git a/packages/core/test-types/fixtures/order-app/app/domain/order/order-placed.ts b/packages/core/test-types/fixtures/order-app/app/domain/order/order-placed.ts index f9fd2ea2..79383f6e 100644 --- a/packages/core/test-types/fixtures/order-app/app/domain/order/order-placed.ts +++ b/packages/core/test-types/fixtures/order-app/app/domain/order/order-placed.ts @@ -1,11 +1,12 @@ import type { Event } from "./+types/order-placed"; -export const payload = ({ z }: Event.PayloadArgs) => +export const payload = ({ z, personal, person }: Event.PayloadArgs) => z.object({ - customerId: z.string(), + customerId: person(z.string()), lines: z.array( z.object({ sku: z.string(), quantity: z.int().positive(), unitPrice: z.number() }), ), + note: personal(z.string()).optional(), }); export const evolve = ({ state, event }: Event.EvolveArgs) => ({ diff --git a/packages/core/test-types/fixtures/order-app/app/domain/order/policies/bounda/+types/audit-on-personal-data-forgotten.ts b/packages/core/test-types/fixtures/order-app/app/domain/order/policies/bounda/+types/audit-on-personal-data-forgotten.ts new file mode 100644 index 00000000..90458cbe --- /dev/null +++ b/packages/core/test-types/fixtures/order-app/app/domain/order/policies/bounda/+types/audit-on-personal-data-forgotten.ts @@ -0,0 +1,10 @@ +import type * as core from "@bounda-dev/core"; +import type * as generated from "../../../../../../.bounda/types.ts"; + +export declare namespace Policy { + type HandlerArgs = core.PolicyHandlerArgs< + core.PersonalDataForgottenEvent, + generated.ReactionCommands, + generated.OrderPorts + >; +} diff --git a/packages/core/test-types/fixtures/order-app/app/domain/order/policies/bounda/audit-on-personal-data-forgotten.ts b/packages/core/test-types/fixtures/order-app/app/domain/order/policies/bounda/audit-on-personal-data-forgotten.ts new file mode 100644 index 00000000..41255915 --- /dev/null +++ b/packages/core/test-types/fixtures/order-app/app/domain/order/policies/bounda/audit-on-personal-data-forgotten.ts @@ -0,0 +1,5 @@ +import type { Policy } from "./+types/audit-on-personal-data-forgotten"; + +export const handler = ({ event, auditLog }: Policy.HandlerArgs) => { + auditLog.record(`forgot ${event.payload.personId}`); +}; diff --git a/packages/core/test-types/fixtures/order-app/app/domain/order/processes/order-payment/customer/on-customer-registered.ts b/packages/core/test-types/fixtures/order-app/app/domain/order/processes/order-payment/customer/on-customer-registered.ts index 27cd91f2..9c20cda2 100644 --- a/packages/core/test-types/fixtures/order-app/app/domain/order/processes/order-payment/customer/on-customer-registered.ts +++ b/packages/core/test-types/fixtures/order-app/app/domain/order/processes/order-payment/customer/on-customer-registered.ts @@ -1,5 +1,5 @@ import type { Process } from "./+types/on-customer-registered"; export const handler = ({ state, event }: Process.HandlerArgs) => ({ - reminders: event.payload.email.length > 0 ? state.reminders : state.reminders + 1, + reminders: event.payload.email === null ? state.reminders : state.reminders + 1, }); diff --git a/packages/core/test-types/fixtures/order-app/app/read/customer-directory/+types/view.ts b/packages/core/test-types/fixtures/order-app/app/read/customer-directory/+types/view.ts new file mode 100644 index 00000000..0777b8f3 --- /dev/null +++ b/packages/core/test-types/fixtures/order-app/app/read/customer-directory/+types/view.ts @@ -0,0 +1,5 @@ +import type * as core from "@bounda-dev/core"; + +export declare namespace View { + type FieldsArgs = core.FieldsArgs; +} diff --git a/packages/core/test-types/fixtures/order-app/app/read/customer-directory/projections/bounda/+types/personal-data-forgotten.ts b/packages/core/test-types/fixtures/order-app/app/read/customer-directory/projections/bounda/+types/personal-data-forgotten.ts new file mode 100644 index 00000000..ac19664d --- /dev/null +++ b/packages/core/test-types/fixtures/order-app/app/read/customer-directory/projections/bounda/+types/personal-data-forgotten.ts @@ -0,0 +1,10 @@ +import type * as core from "@bounda-dev/core"; +import type * as generated from "../../../../../../.bounda/types.ts"; + +export declare namespace Projection { + type Args = core.ProjectionArgs< + core.PersonalDataForgottenEvent, + generated.CustomerDirectoryRow, + unknown + >; +} diff --git a/packages/core/test-types/fixtures/order-app/app/read/customer-directory/projections/bounda/personal-data-forgotten.ts b/packages/core/test-types/fixtures/order-app/app/read/customer-directory/projections/bounda/personal-data-forgotten.ts new file mode 100644 index 00000000..e9f10117 --- /dev/null +++ b/packages/core/test-types/fixtures/order-app/app/read/customer-directory/projections/bounda/personal-data-forgotten.ts @@ -0,0 +1,5 @@ +import type { Projection } from "./+types/personal-data-forgotten"; + +export const project = async ({ event, table }: Projection.Args) => { + await table.delete({ customerId: event.payload.personId }); +}; diff --git a/packages/core/test-types/fixtures/order-app/app/read/customer-directory/projections/customer/+types/customer-registered.ts b/packages/core/test-types/fixtures/order-app/app/read/customer-directory/projections/customer/+types/customer-registered.ts new file mode 100644 index 00000000..a9922522 --- /dev/null +++ b/packages/core/test-types/fixtures/order-app/app/read/customer-directory/projections/customer/+types/customer-registered.ts @@ -0,0 +1,10 @@ +import type * as core from "@bounda-dev/core"; +import type * as generated from "../../../../../../.bounda/types.ts"; + +export declare namespace Projection { + type Args = core.ProjectionArgs< + core.StoredEventOf, + generated.CustomerDirectoryRow, + unknown + >; +} diff --git a/packages/core/test-types/fixtures/order-app/app/read/customer-directory/projections/customer/customer-registered.ts b/packages/core/test-types/fixtures/order-app/app/read/customer-directory/projections/customer/customer-registered.ts new file mode 100644 index 00000000..04e9039b --- /dev/null +++ b/packages/core/test-types/fixtures/order-app/app/read/customer-directory/projections/customer/customer-registered.ts @@ -0,0 +1,8 @@ +import type { Projection } from "./+types/customer-registered"; + +export const project = async ({ event, table }: Projection.Args) => { + await table.upsert({ + customerId: event.aggregateId, + ...(event.payload.email === null ? {} : { email: event.payload.email }), + }); +}; diff --git a/packages/core/test-types/fixtures/order-app/app/read/customer-directory/queries/+types/get-customer.ts b/packages/core/test-types/fixtures/order-app/app/read/customer-directory/queries/+types/get-customer.ts new file mode 100644 index 00000000..e2b28948 --- /dev/null +++ b/packages/core/test-types/fixtures/order-app/app/read/customer-directory/queries/+types/get-customer.ts @@ -0,0 +1,22 @@ +import type * as core from "@bounda-dev/core"; +import type * as generated from "../../../../../.bounda/types.ts"; + +type Module = typeof import("../get-customer.ts"); +type Row = generated.CustomerDirectoryRow; + +export declare namespace Query { + type PayloadArgs = core.PayloadArgs; + type RepositoryArgs = core.QueryRepositoryArgs< + core.PayloadOf, + Row, + unknown + >; + type HandlerArgs = core.QueryHandlerArgs< + "GetCustomer", + core.PayloadOf, + core.RepositoryDataOf, + Row, + generated.Queries, + generated.CustomerDirectoryPorts + >; +} diff --git a/packages/core/test-types/fixtures/order-app/app/read/customer-directory/queries/get-customer.ts b/packages/core/test-types/fixtures/order-app/app/read/customer-directory/queries/get-customer.ts new file mode 100644 index 00000000..8b852f09 --- /dev/null +++ b/packages/core/test-types/fixtures/order-app/app/read/customer-directory/queries/get-customer.ts @@ -0,0 +1,8 @@ +import type { Query } from "./+types/get-customer"; + +export const payload = ({ z }: Query.PayloadArgs) => z.object({ customerId: z.uuid() }); + +export const repository = ({ table, customerId }: Query.RepositoryArgs) => + table.findOne({ customerId }); + +export const handler = ({ repositoryData }: Query.HandlerArgs) => repositoryData; diff --git a/packages/core/test-types/fixtures/order-app/app/read/customer-directory/view.ts b/packages/core/test-types/fixtures/order-app/app/read/customer-directory/view.ts new file mode 100644 index 00000000..87d7eeba --- /dev/null +++ b/packages/core/test-types/fixtures/order-app/app/read/customer-directory/view.ts @@ -0,0 +1,6 @@ +import type { View } from "./+types/view"; + +export const fields = ({ f }: View.FieldsArgs) => ({ + customerId: f.string().primaryKey(), + email: f.string().optional(), +}); diff --git a/packages/core/test-types/fixtures/order-app/app/read/order-summary/projections/bounda/+types/personal-data-forgotten.ts b/packages/core/test-types/fixtures/order-app/app/read/order-summary/projections/bounda/+types/personal-data-forgotten.ts new file mode 100644 index 00000000..e2c7659b --- /dev/null +++ b/packages/core/test-types/fixtures/order-app/app/read/order-summary/projections/bounda/+types/personal-data-forgotten.ts @@ -0,0 +1,10 @@ +import type * as core from "@bounda-dev/core"; +import type * as generated from "../../../../../../.bounda/types.ts"; + +export declare namespace Projection { + type Args = core.ProjectionArgs< + core.PersonalDataForgottenEvent, + generated.OrderSummaryRow, + unknown + >; +} diff --git a/packages/core/test-types/fixtures/order-app/app/read/order-summary/projections/bounda/personal-data-forgotten.ts b/packages/core/test-types/fixtures/order-app/app/read/order-summary/projections/bounda/personal-data-forgotten.ts new file mode 100644 index 00000000..e9f10117 --- /dev/null +++ b/packages/core/test-types/fixtures/order-app/app/read/order-summary/projections/bounda/personal-data-forgotten.ts @@ -0,0 +1,5 @@ +import type { Projection } from "./+types/personal-data-forgotten"; + +export const project = async ({ event, table }: Projection.Args) => { + await table.delete({ customerId: event.payload.personId }); +}; diff --git a/packages/core/test-types/inference.test-d.ts b/packages/core/test-types/inference.test-d.ts index 35eda922..99c8648d 100644 --- a/packages/core/test-types/inference.test-d.ts +++ b/packages/core/test-types/inference.test-d.ts @@ -18,10 +18,17 @@ import type { EmptyPayload, EnvSection, EventMetadata, + EventPayloadArgs, + ForgetsPersonalData, + HasPersonalFields, ImplementationModule, Instant, Logger, PayloadArgs, + PersonalDataForgottenEvent, + PersonalDataForgottenPayload, + PersonalFunction, + PersonFunction, ProcessCorrelation, QueriesFacade, ReactionDispatchResult, @@ -38,7 +45,12 @@ import type { import { idempotencyKeyFor } from "@bounda-dev/core"; import { describe, expectTypeOf, it } from "vitest"; import type { registry } from "./fixtures/order-app/.bounda/registry.ts"; -import type { Commands, Queries } from "./fixtures/order-app/.bounda/types.ts"; +import type { + Commands, + CustomerDirectoryForgetting, + OrderSummaryForgetting, + Queries, +} from "./fixtures/order-app/.bounda/types.ts"; import type { Event as CustomerRegistered } from "./fixtures/order-app/app/domain/customer/+types/customer-registered.ts"; import type { Command as RegisterCustomer } from "./fixtures/order-app/app/domain/customer/commands/+types/register-customer.ts"; import type { Event as OrderPlaced } from "./fixtures/order-app/app/domain/order/+types/order-placed.ts"; @@ -47,12 +59,15 @@ import type { Command as PayOrder } from "./fixtures/order-app/app/domain/order/ import type { Command as PlaceOrder } from "./fixtures/order-app/app/domain/order/commands/+types/place-order.ts"; import type { Policy as NotifyOnOrderPlaced } from "./fixtures/order-app/app/domain/order/policies/+types/notify-on-order-placed.ts"; import type { Policy as SendReceipt } from "./fixtures/order-app/app/domain/order/policies/+types/send-receipt-on-order-paid.ts"; +import type { Policy as AuditOnPersonalDataForgotten } from "./fixtures/order-app/app/domain/order/policies/bounda/+types/audit-on-personal-data-forgotten.ts"; import type { Policy as GreetOnCustomerRegistered } from "./fixtures/order-app/app/domain/order/policies/customer/+types/greet-on-customer-registered.ts"; import type { Process as AtNextReminder } from "./fixtures/order-app/app/domain/order/processes/order-payment/+types/at-next-reminder.ts"; import type { Process as AtTimeout } from "./fixtures/order-app/app/domain/order/processes/order-payment/+types/at-timeout.ts"; import type { Process as OrderPayment } from "./fixtures/order-app/app/domain/order/processes/order-payment/+types/index.ts"; import type { Process as OnOrderPaid } from "./fixtures/order-app/app/domain/order/processes/order-payment/+types/on-order-paid.ts"; import type { Process as OnCustomerRegistered } from "./fixtures/order-app/app/domain/order/processes/order-payment/customer/+types/on-customer-registered.ts"; +import type { Projection as ProjectCustomerRegistered } from "./fixtures/order-app/app/read/customer-directory/projections/customer/+types/customer-registered.ts"; +import type { Projection as ProjectForgotten } from "./fixtures/order-app/app/read/order-summary/projections/bounda/+types/personal-data-forgotten.ts"; import type { Projection as ProjectOrderPaid } from "./fixtures/order-app/app/read/order-summary/projections/order/+types/order-paid.ts"; import type { Query as CustomerOverview } from "./fixtures/order-app/app/read/order-summary/queries/+types/customer-overview.ts"; import type { Query as GetOrder } from "./fixtures/order-app/app/read/order-summary/queries/+types/get-order.ts"; @@ -408,10 +423,95 @@ describe("policies", () => { }); }); +describe("personal data", () => { + type CustomerRegisteredModule = + typeof import("./fixtures/order-app/app/domain/customer/customer-registered.ts"); + type OrderPlacedModule = typeof import("./fixtures/order-app/app/domain/order/order-placed.ts"); + type OrderPaidModule = typeof import("./fixtures/order-app/app/domain/order/order-paid.ts"); + + it("gives an event's payload the marks, and a command's or a query's none", () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().not.toHaveProperty("personal"); + }); + + it("writes a personal field as its schema takes it and reads it as that or null", () => { + expectTypeOf() + .parameter(0) + .toEqualTypeOf<{ email: string }>(); + expectTypeOf().toEqualTypeOf< + string | null + >(); + expectTypeOf().toEqualTypeOf< + string | null + >(); + expectTypeOf().parameter(0).toMatchTypeOf<{ + customerId: string; + note?: string | undefined; + }>(); + expectTypeOf().toEqualTypeOf< + string | null | undefined + >(); + }); + + it("leaves the person field's type as it is", () => { + expectTypeOf< + OrderPlaced.EvolveArgs["event"]["payload"]["customerId"] + >().toEqualTypeOf(); + expectTypeOf() + .parameter(0) + .toHaveProperty("customerId") + .toEqualTypeOf(); + }); + + it("tells the modules with personal fields apart, wrapped or not", () => { + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf object }>>().toEqualTypeOf(); + }); + + it("requires a read model projecting personal fields to handle forgetting", () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf< + ForgetsPersonalData<"x", OrderPaidModule | OrderPlacedModule, true> + >().toEqualTypeOf(); + expectTypeOf< + ForgetsPersonalData<"orderSummary", OrderPaidModule | OrderPlacedModule, false> + >().toEqualTypeOf<"Read model orderSummary projects personal data, so it needs projections/bounda/personal-data-forgotten.ts">(); + }); + + it("types the runtime's event for the projections and policies that handle it", () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf< + ProjectForgotten.Args["event"]["payload"] + >().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<"PersonalDataForgotten">(); + expectTypeOf().toEqualTypeOf>(); + expectTypeOf< + AuditOnPersonalDataForgotten.HandlerArgs["event"] + >().toEqualTypeOf(); + expectTypeOf().toHaveProperty("record"); + }); + + it("lets every client forget a person, answering like a command", () => { + expectTypeOf().toEqualTypeOf< + (personId: string) => Promise + >(); + expectTypeOf().toEqualTypeOf(); + }); +}); + describe("policies for another aggregate's events", () => { it("receive the event of the aggregate their folder names", () => { expectTypeOf().toEqualTypeOf< - StoredEvent<"CustomerRegistered", { email: string }> + StoredEvent<"CustomerRegistered", { email: string | null }> >(); expectTypeOf().toHaveProperty("payOrder"); }); @@ -436,7 +536,7 @@ describe("processes", () => { type From = OrderPayment.CorrelateArgs["from"]["customer"]["CustomerRegistered"]; type Correlator = Parameters[0]; expectTypeOf[0]>().toEqualTypeOf< - StoredEvent<"CustomerRegistered", { email: string }> + StoredEvent<"CustomerRegistered", { email: string | null }> >(); expectTypeOf>().toEqualTypeOf(); expectTypeOf>().toEqualTypeOf(); @@ -446,7 +546,7 @@ describe("processes", () => { it("infer the event of a correlate entry without annotating it", () => { const correlate = ({ from }: OrderPayment.CorrelateArgs) => [ from.customer.CustomerRegistered((event) => { - expectTypeOf(event.payload).toEqualTypeOf<{ email: string }>(); + expectTypeOf(event.payload).toEqualTypeOf<{ email: string | null }>(); return null; }), ]; @@ -455,7 +555,7 @@ describe("processes", () => { it("type a handler for another aggregate's event against that aggregate", () => { expectTypeOf().toEqualTypeOf< - StoredEvent<"CustomerRegistered", { email: string }> + StoredEvent<"CustomerRegistered", { email: string | null }> >(); expectTypeOf().toEqualTypeOf(); }); diff --git a/packages/core/test-types/negatives.test-d.ts b/packages/core/test-types/negatives.test-d.ts index 963eb7bb..b2ce88b1 100644 --- a/packages/core/test-types/negatives.test-d.ts +++ b/packages/core/test-types/negatives.test-d.ts @@ -1,5 +1,7 @@ import { DomainError, + type EventPayloadArgs, + type ForgetsPersonalData, type ImplementationModule, type Instant, type PolicyModule, @@ -11,6 +13,7 @@ import type { AdapterDefinition } from "@bounda-dev/core/adapter"; import { defineConfig } from "@bounda-dev/core/config"; import { describe, it } from "vitest"; import type { Commands } from "./fixtures/order-app/.bounda/types.ts"; +import type { Event as CustomerRegistered } from "./fixtures/order-app/app/domain/customer/+types/customer-registered.ts"; import type { Command as RegisterCustomer } from "./fixtures/order-app/app/domain/customer/commands/+types/register-customer.ts"; import type { Command as PayOrder } from "./fixtures/order-app/app/domain/order/commands/+types/pay-order.ts"; import type { Command as PlaceOrder } from "./fixtures/order-app/app/domain/order/commands/+types/place-order.ts"; @@ -18,6 +21,7 @@ import type { Inventory } from "./fixtures/order-app/app/domain/order/inventory. import type { Process as AtNextReminder } from "./fixtures/order-app/app/domain/order/processes/order-payment/+types/at-next-reminder.ts"; import type { Process as OrderPayment } from "./fixtures/order-app/app/domain/order/processes/order-payment/+types/index.ts"; import type { Process as OnOrderPaid } from "./fixtures/order-app/app/domain/order/processes/order-payment/+types/on-order-paid.ts"; +import type { Projection as ProjectCustomerRegistered } from "./fixtures/order-app/app/read/customer-directory/projections/customer/+types/customer-registered.ts"; import type { Projection as ProjectOrderPlaced } from "./fixtures/order-app/app/read/order-summary/projections/order/+types/order-placed.ts"; @@ -173,6 +177,50 @@ describe("what does not compile", () => { void handler; }); + it("marking personal data outside an event, or reading it as if it could not be forgotten", () => { + // @ts-expect-error a command's payload has no personal: commands are not stored + const command = ({ z, personal }: RegisterCustomer.PayloadArgs) => + z.object({ email: personal(z.email()) }); + void command; + const builder = ({ events }: RegisterCustomer.HandlerArgs) => [ + // @ts-expect-error a personal field is written as its schema takes it, never as null + events.customerRegistered({ email: null }), + ]; + void builder; + const projection = async ({ event, table }: ProjectCustomerRegistered.Args) => { + // @ts-expect-error a personal field reads as null once its person is forgotten + await table.upsert({ customerId: event.aggregateId, email: event.payload.email }); + }; + void projection; + const evolve = ({ event }: CustomerRegistered.EvolveArgs) => + // @ts-expect-error the same in evolve + event.payload.email.toLowerCase(); + void evolve; + const marks = ({ z, personal, person }: EventPayloadArgs) => + z.object({ customerId: person(z.string()), email: personal(z.email()) }); + void marks; + }); + + it("a read model that projects personal fields without handling forgetting", () => { + type Expect = Check; + type Unhandled = ForgetsPersonalData< + "customerDirectory", + typeof import("./fixtures/order-app/app/domain/customer/customer-registered.ts"), + false + >; + // @ts-expect-error the read model needs projections/bounda/personal-data-forgotten.ts + type Refused = Expect; + type Handled = Expect< + ForgetsPersonalData< + "customerDirectory", + typeof import("./fixtures/order-app/app/domain/customer/customer-registered.ts"), + true + > + >; + const checks: [Refused?, Handled?] = []; + void checks; + }); + it("emitting an event of another aggregate", () => { const handler = ({ events }: PlaceOrder.HandlerArgs) => [ // @ts-expect-error customerRegistered belongs to the customer aggregate diff --git a/packages/postgresql/src/index.ts b/packages/postgresql/src/index.ts index 907c88ef..447a2922 100644 --- a/packages/postgresql/src/index.ts +++ b/packages/postgresql/src/index.ts @@ -13,6 +13,7 @@ import { createPostgresqlEventNotifier } from "./event-notifier.ts"; import { createPostgresqlEventStore } from "./event-store.ts"; import { createPostgresqlInboxLedger } from "./inbox-ledger.ts"; import { type PostgresqlOptions, resolvePostgresqlOptions } from "./options.ts"; +import { createPostgresqlPersonalKeyStore } from "./personal-key-store.ts"; import { openPostgresqlReadModel, rebuildPostgresqlReadModel } from "./read-model.ts"; import { createPostgresqlScheduler } from "./scheduler.ts"; import { ensureStorageSchema, storageTablesFor } from "./schema.ts"; @@ -102,6 +103,10 @@ export const postgresql: PostgresqlFunction = (options) => { table: tables.deadLetters, }), scheduler: createPostgresqlScheduler({ db: database, table: tables.scheduledCommands }), + personalKeys: createPostgresqlPersonalKeyStore({ + db: database, + table: tables.personalKeys, + }), }); const boundTo = (tx: SqlTransaction): PostgresqlDatabase => ({ run: tx.run, diff --git a/packages/postgresql/src/personal-key-store.ts b/packages/postgresql/src/personal-key-store.ts new file mode 100644 index 00000000..68d8a817 --- /dev/null +++ b/packages/postgresql/src/personal-key-store.ts @@ -0,0 +1,34 @@ +import type { PersonalKeyStore } from "@bounda-dev/core/adapter"; +import type { SqlExecutor } from "@bounda-dev/core/adapter/sql"; + +export interface CreatePostgresqlPersonalKeyStoreArgs { + readonly db: SqlExecutor; + readonly table: string; +} + +export interface CreatePostgresqlPersonalKeyStoreFunction { + (args: CreatePostgresqlPersonalKeyStoreArgs): PersonalKeyStore; +} + +export const createPostgresqlPersonalKeyStore: CreatePostgresqlPersonalKeyStoreFunction = ({ + db, + table, +}) => { + const get = async (person: string): Promise => { + const [row] = await db.all(`SELECT "key" FROM ${table} WHERE "person" = $1`, [person]); + return row === undefined ? null : String(row.key); + }; + return { + get, + add: async (person, key) => { + const [row] = await db.all( + `INSERT INTO ${table} ("person", "key") VALUES ($1, $2) ON CONFLICT ("person") DO NOTHING RETURNING "key"`, + [person, key], + ); + return row === undefined ? ((await get(person)) ?? key) : String(row.key); + }, + remove: async (person) => + (await db.all(`DELETE FROM ${table} WHERE "person" = $1 RETURNING "person"`, [person])) + .length === 1, + }; +}; diff --git a/packages/postgresql/src/postgresql.test.ts b/packages/postgresql/src/postgresql.test.ts index 2512c3fe..c54c067b 100644 --- a/packages/postgresql/src/postgresql.test.ts +++ b/packages/postgresql/src/postgresql.test.ts @@ -19,6 +19,7 @@ import { eventStoreContract, inboxLedgerContract, pendingEvent, + personalKeyStoreContract, readModelRebuildContract, readModelTransactionContract, schedulerContract, @@ -94,6 +95,7 @@ describe.skipIf(container === null)("postgresql adapter", () => { deadLetterStoreContract({ create: async () => (await openStorage()).deadLetterStore }); schedulerContract({ create: async () => (await openStorage()).scheduler }); storageTransactionContract({ create: () => openStorage() }); + personalKeyStoreContract({ create: async () => (await openStorage()).personalKeys }); it("takes the append lock as a transaction starts, before anything it writes, and lets go of it with the transaction", async () => { const storage = await openStorage(); @@ -394,6 +396,7 @@ describe.skipIf(container === null)("postgresql adapter", () => { "dead_letters", "events", "inbox", + "personal_keys", "rm_order_summary", "scheduled_commands", ]); diff --git a/packages/postgresql/src/schema.ts b/packages/postgresql/src/schema.ts index b0373966..7a0ae2d1 100644 --- a/packages/postgresql/src/schema.ts +++ b/packages/postgresql/src/schema.ts @@ -13,6 +13,7 @@ export interface StorageTables { readonly inbox: string; readonly deadLetters: string; readonly scheduledCommands: string; + readonly personalKeys: string; readonly appendLockKey: string; // The first key of the advisory locks read models take on the checkpoints table. readonly checkpointsLockKey: string; @@ -51,6 +52,7 @@ export const storageTablesFor: StorageTablesForFunction = ({ prefix, schema }) = inbox: quoteIdentifier(storageTableNameFor({ prefix, table: "inbox" })), deadLetters: quoteIdentifier(storageTableNameFor({ prefix, table: "deadLetters" })), scheduledCommands: quoteIdentifier(storageTableNameFor({ prefix, table: "scheduledCommands" })), + personalKeys: quoteIdentifier(storageTableNameFor({ prefix, table: "personalKeys" })), appendLockKey: `bounda:${schema}.${events}`, checkpointsLockKey: `bounda:${schema}.${checkpoints}`, channel, @@ -131,6 +133,10 @@ export const storageSchemaStatements: StorageSchemaStatementsFunction = (tables) "claim_id" text )`, `CREATE INDEX IF NOT EXISTS ${indexName(tables.scheduledCommands, "execute_at")} ON ${tables.scheduledCommands} ("execute_at")`, + `CREATE TABLE IF NOT EXISTS ${tables.personalKeys} ( + "person" text PRIMARY KEY, + "key" text NOT NULL + )`, ]; export interface EnsureStorageSchemaArgs { @@ -156,6 +162,7 @@ export const ensureStorageSchema: EnsureStorageSchemaFunction = async ({ db, sch tables.inbox, tables.deadLetters, tables.scheduledCommands, + tables.personalKeys, indexName(tables.deadLetters, "status"), indexName(tables.scheduledCommands, "execute_at"), ].map(unquoted); diff --git a/packages/react-router/src/cloudflare/clients.test.ts b/packages/react-router/src/cloudflare/clients.test.ts index 11327002..2a27fe52 100644 --- a/packages/react-router/src/cloudflare/clients.test.ts +++ b/packages/react-router/src/cloudflare/clients.test.ts @@ -63,6 +63,7 @@ describe("cloudflareClients", () => { await client.deadLetters.retry("d-1"); await client.deadLetters.discard("d-2"); await client.rebuildReadModel("orders"); + await client.forget("c-1"); expect(store.calls).toEqual([ ["acme", "lag"], ["acme", "readEvents", { after: 3, limit: 10 }], @@ -70,6 +71,7 @@ describe("cloudflareClients", () => { ["acme", "retryDeadLetter", "d-1"], ["acme", "discardDeadLetter", "d-2"], ["acme", "rebuildReadModel", "orders"], + ["acme", "forget", "c-1", "read-your-writes"], ]); }); diff --git a/packages/react-router/src/cloudflare/clients.ts b/packages/react-router/src/cloudflare/clients.ts index 799181e3..f561f4de 100644 --- a/packages/react-router/src/cloudflare/clients.ts +++ b/packages/react-router/src/cloudflare/clients.ts @@ -50,6 +50,7 @@ const lazyStub = (stubOf: () => Promise): BoundaStub => ({ retryDeadLetter: async (id) => (await stubOf()).retryDeadLetter(id), discardDeadLetter: async (id) => (await stubOf()).discardDeadLetter(id), rebuildReadModel: async (name) => (await stubOf()).rebuildReadModel(name), + forget: async (...args) => (await stubOf()).forget(...args), }); export const cloudflareClients: CloudflareClientsFunction = ({ diff --git a/packages/react-router/src/cloudflare/test-support.ts b/packages/react-router/src/cloudflare/test-support.ts index 28712f81..dde36123 100644 --- a/packages/react-router/src/cloudflare/test-support.ts +++ b/packages/react-router/src/cloudflare/test-support.ts @@ -39,6 +39,7 @@ export const storeNamespace = (): FakeStoreNamespace => { retryDeadLetter: record("retryDeadLetter"), discardDeadLetter: record("discardDeadLetter"), rebuildReadModel: record("rebuildReadModel"), + forget: record("forget"), }; }; return { calls, namespace: { idFromName: (name) => name, get: stubOf } }; diff --git a/packages/sqlite/src/sqlite.test.ts b/packages/sqlite/src/sqlite.test.ts index 815f276f..00ab41d5 100644 --- a/packages/sqlite/src/sqlite.test.ts +++ b/packages/sqlite/src/sqlite.test.ts @@ -19,6 +19,7 @@ import { eventStoreContract, inboxLedgerContract, pendingEvent, + personalKeyStoreContract, readModelRebuildContract, readModelTransactionContract, schedulerContract, @@ -42,6 +43,7 @@ describe("sqlite adapter in memory", () => { deadLetterStoreContract({ create: async () => (await openStorage()).deadLetterStore }); schedulerContract({ create: async () => (await openStorage()).scheduler }); storageTransactionContract({ create: () => openStorage() }); + personalKeyStoreContract({ create: async () => (await openStorage()).personalKeys }); tableContract({ create: async () => ( @@ -237,6 +239,7 @@ describe("sqlite adapter on a file", () => { "app_dead_letters", "app_events", "app_inbox", + "app_personal_keys", "app_rm_order_summary", "app_scheduled_commands", ]); From ab7b7a8c494235f9c1c985131765c76bb1d8c6a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Be=C3=B1at=20Espi=C3=B1a?= Date: Sun, 25 Oct 2026 05:37:31 +0100 Subject: [PATCH 2/2] test(cloudflare): wait for the alarm before asserting the lag after forget Under read-your-writes a forget only waits for the read models that handle it; the others catch up in the alarm, which may or may not have fired by the time the test looked, so the assertion raced the runtime. --- .../cloudflare/test/bounda-object.test.ts | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/cloudflare/test/bounda-object.test.ts b/packages/cloudflare/test/bounda-object.test.ts index 1d49204d..3e2fbfbf 100644 --- a/packages/cloudflare/test/bounda-object.test.ts +++ b/packages/cloudflare/test/bounda-object.test.ts @@ -119,8 +119,27 @@ describe("a Bounda Durable Object", () => { await runAlarm(stub); expect(await store.queries.getCustomer({ customer: "ada" })).toBeNull(); - await store.forget("bo"); - expect(await store.queries.getCustomer({ customer: "bo" })).toBeNull(); + // Under read-your-writes the forget waits for the read models that handle it; the others + // catch up in the alarm, which is armed for them. + const forgotten = await runInDurableObject(stub, async (instance, state) => ({ + outcome: await instance.forget("bo"), + found: await instance.query("getCustomer", { customer: "bo" }), + lag: await instance.lag(), + alarm: await state.storage.getAlarm(), + })); + expect(forgotten.outcome).toMatchObject({ ok: true, value: { aggregateId: "bo", version: 1 } }); + expect(forgotten.found).toEqual({ ok: true, value: null }); + expect(forgotten.lag).toMatchObject({ + ok: true, + value: { + subscribers: expect.arrayContaining([ + expect.objectContaining({ subscriber: "projection:customers", lag: 0 }), + expect.objectContaining({ subscriber: "projection:orders", lag: 1 }), + ]), + }, + }); + expect(forgotten.alarm).toBeLessThanOrEqual(Date.now()); + await runAlarm(stub); expect((await store.getLag()).maxLag).toBe(0); expect(await alarmOf(stub)).toBeNull(); });