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..3e2fbfbf 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,79 @@ 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(); + + // 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(); + }); + 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", ]);