diff --git a/.changeset/personal-data.md b/.changeset/personal-data.md new file mode 100644 index 00000000..84326115 --- /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. A command that loaded a person's data before `forget` and appends after it fails with `ConcurrencyError` and is decided again, instead of writing their data back under a new key; `AppendArgs` gains `persons` for this, which a store ignores. 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 8d33ab09..4dfbaf93 100644 --- a/docs/src/content/docs/adapters/index.mdx +++ b/docs/src/content/docs/adapters/index.mdx @@ -89,6 +89,7 @@ On first use the adapter creates its tables, prefixed with `bounda_` by default: | `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_idempotency_keys` | What a command dispatched with an `idempotencyKey` answered, until the key expires | +| `bounda_personal_keys` | One key per person, which their [personal fields](/guides/personal-data/) are encrypted with; `forget` deletes it | | `bounda_snapshots` | The last snapshot of every stream of an aggregate that takes them, with the fingerprint it is valid for | Read models get one table each, named after the read model in snake_case behind `rm_`, so no 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..5c2e4dcd --- /dev/null +++ b/docs/src/content/docs/concepts/forgetting-in-an-immutable-log.md @@ -0,0 +1,153 @@ +--- +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. The runtime's own copies are its job: +[snapshots](/concepts/long-streams/#snapshots), which hold an aggregate's state in the clear, +go with the key, in the same transaction. 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. + A dead letter also keeps the error a handler threw, message and stack: one that quotes a + decrypted field keeps it in the clear. +- **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/concepts/long-streams.md b/docs/src/content/docs/concepts/long-streams.md index b8b8acb2..5d98ab60 100644 --- a/docs/src/content/docs/concepts/long-streams.md +++ b/docs/src/content/docs/concepts/long-streams.md @@ -163,6 +163,11 @@ nested plain objects and arrays come back as they were. A class instance or a fu state cannot be kept: the subscriber logs it and writes no snapshot for that stream, and a test app throws `SnapshotError` from `runUntilIdle()`. Keep the state to data. +The state is kept as it was folded, in the clear, [personal fields](/guides/personal-data/) +included. Each snapshot names the people whose fields it holds, and forgetting one of them +removes their snapshots in the same transaction that removes their key, so no snapshot keeps +what the events no longer show. + ## What snapshots do not do - **Speed up a rebuild.** A rebuild runs projections over the global stream; it never folds an diff --git a/docs/src/content/docs/guides/idempotent-commands.md b/docs/src/content/docs/guides/idempotent-commands.md index bb1b0ebc..afe1aff9 100644 --- a/docs/src/content/docs/guides/idempotent-commands.md +++ b/docs/src/content/docs/guides/idempotent-commands.md @@ -40,6 +40,9 @@ means to do once, such as a UUID made when the form is submitted and sent again it. It is remembered for `runtime.commands.idempotencyKeyLifetime`, 24 hours by default; past it the key is free for another command, and the scheduled-command worker purges the record from `bounda_idempotency_keys` ([configuration](/reference/configuration/#commands)). +A rejection decided on a state that held someone's [personal data](/guides/personal-data/) is +remembered only until that person is forgotten: `forget` removes it, and the same key is then +decided again. This is not the `idempotencyKey` a command handler receives: that one is the command's own id, for the provider the handler calls, the same across the runs a concurrency conflict causes 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..987f1416 --- /dev/null +++ b/docs/src/content/docs/guides/personal-data.md @@ -0,0 +1,173 @@ +--- +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. A command that loaded +their data before `forget` and appends after it is not the one to give them that key: its append +fails with [`ConcurrencyError`](/reference/errors/), as on a stream that moved, and the retry +decides on the state without their data. + +A [snapshot](/concepts/long-streams/#snapshots) holds the state in the clear, so the same +transaction removes every snapshot whose state carried a field of theirs; each one names the +people it holds, and none is written for a person without a key. The next command on such an +aggregate folds it from its first event, with the fields forgotten, and the snapshot after it +holds them as `null`. + +A command dispatched with an [`idempotencyKey`](/guides/idempotent-commands/) remembers +what it answered, and a rejection's message may quote the state it was decided on. Such a +rejection names the people whose fields were in that state, and `forget` removes it too: the same +request sent again is decided again, on the state without them. What it cannot see is personal +data that came in the command's payload, which is never marked: keep that out of a rejection's +message and say what went wrong with its code and the ids involved. + +## 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 c732fe54..ad335acc 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/errors.md b/docs/src/content/docs/reference/errors.md index c4ee3bb1..2e72dc57 100644 --- a/docs/src/content/docs/reference/errors.md +++ b/docs/src/content/docs/reference/errors.md @@ -39,8 +39,10 @@ dead letter with `errorType: "retriable_exhausted"`. state or the `after` and `limit` of a [read of the global stream](/guides/reading-the-stream/) did not validate. `issues` lists each problem with its `path`. - **`CONCURRENCY_CONFLICT`** (`ConcurrencyError`; retried). An append found the stream at another - version, after `runtime.commands.concurrencyRetries` runs of the command. `streamId`, - `expectedVersion`, `actualVersion`. + version, or, with both versions equal, a person whose [personal data](/guides/personal-data/) + the command loaded was forgotten before it appended; after + `runtime.commands.concurrencyRetries` runs of the command. `streamId`, `expectedVersion`, + `actualVersion`. - **`HANDLER_TIMEOUT`** (no exported class; retried). A command, policy or process handler ran out of time (`runtime.commands.timeout`, `runtime.policies.timeout`, `runtime.processes.handlerTimeout`). diff --git a/docs/src/content/docs/reference/limitations.md b/docs/src/content/docs/reference/limitations.md index 6504dcf5..9ce89ee6 100644 --- a/docs/src/content/docs/reference/limitations.md +++ b/docs/src/content/docs/reference/limitations.md @@ -21,9 +21,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, [snapshots](/concepts/long-streams/#snapshots) whose validity the runtime checks itself, [observability](/reference/observability/) through OpenTelemetry, and a 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 0deefb17..a63ec272 100644 --- a/packages/cli/src/generate/emit/emit.test.ts +++ b/packages/cli/src/generate/emit/emit.test.ts @@ -432,12 +432,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", () => { @@ -604,6 +657,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 2c80e308..4ffaf671 100644 --- a/packages/cloudflare/test/storage-contracts.test.ts +++ b/packages/cloudflare/test/storage-contracts.test.ts @@ -10,6 +10,7 @@ import { eventStoreContract, idempotencyStoreContract, inboxLedgerContract, + personalKeyStoreContract, readModelRebuildContract, readModelTransactionContract, schedulerContract, @@ -38,9 +39,10 @@ describe("the Durable Object adapter", () => { inboxLedgerContract({ create: async () => (await freshStorage()).inboxLedger }); deadLetterStoreContract({ create: async () => (await freshStorage()).deadLetterStore }); schedulerContract({ create: async () => (await freshStorage()).scheduler }); - idempotencyStoreContract({ create: async () => (await freshStorage()).idempotencyStore }); - snapshotStoreContract({ create: async () => (await freshStorage()).snapshotStore }); + idempotencyStoreContract({ create: freshStorage }); + snapshotStoreContract({ create: freshStorage }); 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 8ad1bd79..1173dd9d 100644 --- a/packages/core/src/adapter/adapter.ts +++ b/packages/core/src/adapter/adapter.ts @@ -7,6 +7,7 @@ import type { EventNotifier } from "./storage/event-notifier.ts"; import type { EventStore } from "./storage/event-store.ts"; import type { IdempotencyStore } from "./storage/idempotency-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 { SnapshotStore } from "./storage/snapshot-store.ts"; import type { ReadClient, Table } from "./storage/table.ts"; @@ -24,6 +25,12 @@ export interface StorageTransaction { readonly deadLetterStore: DeadLetterStore; readonly scheduler: Scheduler; readonly idempotencyStore: IdempotencyStore; + readonly personalKeys: PersonalKeyStore; + /** + * So forgetting a person removes their snapshots with their key. Snapshots are a cache, so an + * adapter may remove them at once rather than when the transaction commits. + */ + readonly snapshotStore: SnapshotStore; } /** @@ -36,6 +43,7 @@ export interface Storage { readonly deadLetterStore: DeadLetterStore; readonly scheduler: Scheduler; readonly idempotencyStore: IdempotencyStore; + readonly personalKeys: PersonalKeyStore; /** * Written by the snapshot subscriber, outside any command's transaction, and read by every * command of an aggregate with snapshots. diff --git a/packages/core/src/adapter/deferred-writes.test.ts b/packages/core/src/adapter/deferred-writes.test.ts new file mode 100644 index 00000000..4048b41f --- /dev/null +++ b/packages/core/src/adapter/deferred-writes.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { silentLogger } from "../contracts/logger.ts"; +import { memory } from "../memory/index.ts"; +import { deferWrites } from "./deferred-writes.ts"; +import { testOutcome } from "./testing/fixtures.ts"; + +const now = new Date("2026-01-01T00:00:00.000Z"); +const record = (key: string, persons: readonly string[]) => ({ + key, + fingerprint: "fp", + outcome: testOutcome(key), + expiresAt: "2026-01-02T00:00:00.000Z", + persons, + now, +}); + +describe("deferWrites", () => { + it("forgets the idempotency records it holds back for a person, and forgets them on flush", async () => { + const storage = await memory().createStorage({ logger: silentLogger }); + await storage.personalKeys.add("c-1", "k-1"); + await storage.idempotencyStore.put(record("live", ["c-1"])); + const { stores, flush } = deferWrites(storage); + await stores.idempotencyStore.put(record("held", ["c-1"])); + await stores.idempotencyStore.put(record("other", [])); + await stores.idempotencyStore.forget("c-1"); + expect(await stores.idempotencyStore.get({ key: "held", now })).toBeNull(); + expect(await stores.idempotencyStore.get({ key: "other", now })).not.toBeNull(); + expect(await storage.idempotencyStore.get({ key: "live", now })).not.toBeNull(); + await flush(storage); + expect(await storage.idempotencyStore.get({ key: "live", now })).toBeNull(); + expect(await storage.idempotencyStore.get({ key: "held", now })).toBeNull(); + expect(await storage.idempotencyStore.get({ key: "other", now })).not.toBeNull(); + }); +}); diff --git a/packages/core/src/adapter/deferred-writes.ts b/packages/core/src/adapter/deferred-writes.ts index 89f19eda..ec304994 100644 --- a/packages/core/src/adapter/deferred-writes.ts +++ b/packages/core/src/adapter/deferred-writes.ts @@ -19,7 +19,7 @@ export interface DeferredWriteTarget { DeferredStores["scheduler"], "schedule" | "cancel" | "complete" | "fail" | "defer" >; - readonly idempotencyStore: Pick; + readonly idempotencyStore: Pick; } /** @@ -101,6 +101,12 @@ export const deferWrites: DeferWritesFunction = ({ recorded.push((target) => target.idempotencyStore.put(args)); }, purge: idempotencyStore.purge, + forget: async (person) => { + for (const [key, record] of records) { + if (record.persons.includes(person)) records.delete(key); + } + recorded.push((target) => target.idempotencyStore.forget(person)); + }, }, }, flush: async (target) => { diff --git a/packages/core/src/adapter/index.ts b/packages/core/src/adapter/index.ts index 2363ec91..a943e9ee 100644 --- a/packages/core/src/adapter/index.ts +++ b/packages/core/src/adapter/index.ts @@ -53,6 +53,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 22fe9608..98c0d585 100644 --- a/packages/core/src/adapter/sqlite/adapter.ts +++ b/packages/core/src/adapter/sqlite/adapter.ts @@ -10,6 +10,7 @@ import { createSqliteDeadLetterStore } from "./dead-letter-store.ts"; import { createSqliteEventStore } from "./event-store.ts"; import { createSqliteIdempotencyStore } from "./idempotency-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"; @@ -92,6 +93,13 @@ export const createSqliteAdapter: CreateSqliteAdapterFunction = < idempotencyStore: createSqliteIdempotencyStore({ db: database, table: tables.idempotencyKeys, + personalKeys: tables.personalKeys, + }), + personalKeys: createSqlitePersonalKeyStore({ db: database, table: tables.personalKeys }), + snapshotStore: createSqliteSnapshotStore({ + db: database, + table: tables.snapshots, + personalKeys: tables.personalKeys, }), }); // The stores over an open transaction: their statements join it, and a `write` of their own @@ -104,7 +112,6 @@ export const createSqliteAdapter: CreateSqliteAdapterFunction = < return { ...storesOver(db), checkpointStore: createSqliteCheckpointStore({ db, table: tables.checkpoints }), - snapshotStore: createSqliteSnapshotStore({ db, table: tables.snapshots }), transact: (work) => db.write((tx) => work(storesOver(boundTo(tx)))), close: release, }; diff --git a/packages/core/src/adapter/sqlite/idempotency-store.ts b/packages/core/src/adapter/sqlite/idempotency-store.ts index 17d84a41..990ac958 100644 --- a/packages/core/src/adapter/sqlite/idempotency-store.ts +++ b/packages/core/src/adapter/sqlite/idempotency-store.ts @@ -5,6 +5,8 @@ import type { SqlDatabase } from "../sql/database.ts"; export interface CreateSqliteIdempotencyStoreArgs { readonly db: SqlDatabase; readonly table: string; + // The personal keys a record's persons must hold for `put` to write it. + readonly personalKeys: string; } export interface CreateSqliteIdempotencyStoreFunction { @@ -16,38 +18,62 @@ const toRecord = (row: Record): IdempotencyRecord => ({ fingerprint: String(row.fingerprint), outcome: JSON.parse(String(row.outcome)) as DispatchOutcome, expiresAt: String(row.expires_at), + persons: JSON.parse(String(row.persons)) as string[], }); export const createSqliteIdempotencyStore: CreateSqliteIdempotencyStoreFunction = ({ db, table, -}) => ({ - get: async ({ key, now }) => { + personalKeys, +}) => { + const get: IdempotencyStore["get"] = async ({ key, now }) => { const [row] = await db.all( - `SELECT "key", "fingerprint", "outcome", "expires_at" FROM ${table} WHERE "key" = ? AND "expires_at" > ?`, + `SELECT "key", "fingerprint", "outcome", "expires_at", "persons" FROM ${table} WHERE "key" = ? AND "expires_at" > ?`, [key, now.toISOString()], ); return row === undefined ? null : toRecord(row); - }, - // One statement whose row comes back only when the key was free or expired, so two dispatches - // racing for it can never both record. - put: async ({ key, fingerprint, outcome, expiresAt, now }) => { - const rows = await db.all( - `INSERT INTO ${table} ("key", "fingerprint", "outcome", "expires_at") VALUES (?, ?, ?, ?) + }; + return { + get, + // One statement whose row comes back only when the key was free or expired and every person + // the record names has a key, so two dispatches racing for it can never both record, nor one + // record beside a forget. No row back with no live record under the key is the second case. + put: async ({ key, fingerprint, outcome, expiresAt, now, persons }) => { + const rows = await db.all( + `INSERT INTO ${table} ("key", "fingerprint", "outcome", "expires_at", "persons") + SELECT ?, ?, ?, ?, ? WHERE (SELECT count(*) FROM ${personalKeys} WHERE "person" IN (SELECT "value" FROM json_each(?))) = ? ON CONFLICT ("key") DO UPDATE SET "fingerprint" = excluded."fingerprint", "outcome" = excluded."outcome", - "expires_at" = excluded."expires_at" + "expires_at" = excluded."expires_at", + "persons" = excluded."persons" WHERE ${table}."expires_at" <= ? RETURNING "key"`, - [key, fingerprint, JSON.stringify(outcome), expiresAt, now.toISOString()], - ); - if (rows.length === 0) throw new IdempotencyKeyTakenError(key); - }, - purge: async ({ now }) => { - const rows = await db.all(`DELETE FROM ${table} WHERE "expires_at" <= ? RETURNING "key"`, [ - now.toISOString(), - ]); - return rows.length; - }, -}); + [ + key, + fingerprint, + JSON.stringify(outcome), + expiresAt, + JSON.stringify(persons), + JSON.stringify(persons), + persons.length, + now.toISOString(), + ], + ); + if (rows.length === 0 && (await get({ key, now })) !== null) { + throw new IdempotencyKeyTakenError(key); + } + }, + purge: async ({ now }) => { + const rows = await db.all(`DELETE FROM ${table} WHERE "expires_at" <= ? RETURNING "key"`, [ + now.toISOString(), + ]); + return rows.length; + }, + forget: (person) => + db.run( + `DELETE FROM ${table} WHERE EXISTS (SELECT 1 FROM json_each(${table}."persons") WHERE "value" = ?)`, + [person], + ), + }; +}; 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..3776da1d --- /dev/null +++ b/packages/core/src/adapter/sqlite/personal-key-store.ts @@ -0,0 +1,35 @@ +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, + // Whichever writer inserted first, every one answers with the key in force. + add: async (person, key) => { + await db.run( + `INSERT INTO ${table} ("person", "key") VALUES (?, ?) ON CONFLICT ("person") DO NOTHING`, + [person, key], + ); + return (await get(person)) ?? 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 329b90a8..3e664847 100644 --- a/packages/core/src/adapter/sqlite/schema.ts +++ b/packages/core/src/adapter/sqlite/schema.ts @@ -9,6 +9,7 @@ export interface StorageTables { readonly deadLetters: string; readonly scheduledCommands: string; readonly idempotencyKeys: string; + readonly personalKeys: string; readonly snapshots: string; } @@ -23,6 +24,7 @@ export const storageTablesFor: StorageTablesForFunction = (prefix) => ({ deadLetters: quoteIdentifier(storageTableNameFor({ prefix, table: "deadLetters" })), scheduledCommands: quoteIdentifier(storageTableNameFor({ prefix, table: "scheduledCommands" })), idempotencyKeys: quoteIdentifier(storageTableNameFor({ prefix, table: "idempotencyKeys" })), + personalKeys: quoteIdentifier(storageTableNameFor({ prefix, table: "personalKeys" })), snapshots: quoteIdentifier(storageTableNameFor({ prefix, table: "snapshots" })), }); @@ -108,15 +110,21 @@ export const storageSchemaStatements: StorageSchemaStatementsFunction = (tables) "key" TEXT PRIMARY KEY, "fingerprint" TEXT NOT NULL, "outcome" TEXT NOT NULL, - "expires_at" TEXT NOT NULL + "expires_at" TEXT NOT NULL, + "persons" TEXT NOT NULL )`, `CREATE INDEX IF NOT EXISTS ${indexName(tables.idempotencyKeys, "expires_at")} ON ${tables.idempotencyKeys} ("expires_at")`, + `CREATE TABLE IF NOT EXISTS ${tables.personalKeys} ( + "person" TEXT PRIMARY KEY, + "key" TEXT NOT NULL + )`, `CREATE TABLE IF NOT EXISTS ${tables.snapshots} ( "aggregate_type" TEXT NOT NULL, "aggregate_id" TEXT NOT NULL, "version" INTEGER NOT NULL, "fingerprint" TEXT NOT NULL, "state" TEXT NOT NULL, + "persons" TEXT NOT NULL, PRIMARY KEY ("aggregate_type", "aggregate_id") )`, ]; diff --git a/packages/core/src/adapter/sqlite/snapshot-store.ts b/packages/core/src/adapter/sqlite/snapshot-store.ts index 114ab0f9..df744413 100644 --- a/packages/core/src/adapter/sqlite/snapshot-store.ts +++ b/packages/core/src/adapter/sqlite/snapshot-store.ts @@ -4,6 +4,8 @@ import type { SqlExecutor } from "../sql/sql-table.ts"; export interface CreateSqliteSnapshotStoreArgs { readonly db: SqlExecutor; readonly table: string; + // The personal keys a snapshot's persons must hold for `put` to write it. + readonly personalKeys: string; } export interface CreateSqliteSnapshotStoreFunction { @@ -16,24 +18,44 @@ const toSnapshot = (row: Record): Snapshot => ({ version: Number(row.version), fingerprint: String(row.fingerprint), state: String(row.state), + persons: JSON.parse(String(row.persons)) as string[], }); -export const createSqliteSnapshotStore: CreateSqliteSnapshotStoreFunction = ({ db, table }) => ({ +export const createSqliteSnapshotStore: CreateSqliteSnapshotStoreFunction = ({ + db, + table, + personalKeys, +}) => ({ get: async ({ aggregateType, aggregateId }) => { const [row] = await db.all( - `SELECT "aggregate_type", "aggregate_id", "version", "fingerprint", "state" FROM ${table} WHERE "aggregate_type" = ? AND "aggregate_id" = ?`, + `SELECT "aggregate_type", "aggregate_id", "version", "fingerprint", "state", "persons" FROM ${table} WHERE "aggregate_type" = ? AND "aggregate_id" = ?`, [aggregateType, aggregateId], ); return row === undefined ? null : toSnapshot(row); }, - put: ({ aggregateType, aggregateId, version, fingerprint, state }) => + // One statement, so a forget that removed a key before it runs leaves it nothing to write. + put: ({ aggregateType, aggregateId, version, fingerprint, state, persons }) => db.run( - `INSERT INTO ${table} ("aggregate_type", "aggregate_id", "version", "fingerprint", "state") VALUES (?, ?, ?, ?, ?) ON CONFLICT ("aggregate_type", "aggregate_id") DO UPDATE SET "version" = excluded."version", "fingerprint" = excluded."fingerprint", "state" = excluded."state"`, - [aggregateType, aggregateId, version, fingerprint, state], + `INSERT INTO ${table} ("aggregate_type", "aggregate_id", "version", "fingerprint", "state", "persons") SELECT ?, ?, ?, ?, ?, ? WHERE (SELECT count(*) FROM ${personalKeys} WHERE "person" IN (SELECT "value" FROM json_each(?))) = ? ON CONFLICT ("aggregate_type", "aggregate_id") DO UPDATE SET "version" = excluded."version", "fingerprint" = excluded."fingerprint", "state" = excluded."state", "persons" = excluded."persons"`, + [ + aggregateType, + aggregateId, + version, + fingerprint, + state, + JSON.stringify(persons), + JSON.stringify(persons), + persons.length, + ], ), remove: ({ aggregateType, aggregateId }) => db.run(`DELETE FROM ${table} WHERE "aggregate_type" = ? AND "aggregate_id" = ?`, [ aggregateType, aggregateId, ]), + forget: (person) => + db.run( + `DELETE FROM ${table} WHERE EXISTS (SELECT 1 FROM json_each(${table}."persons") WHERE "value" = ?)`, + [person], + ), }); diff --git a/packages/core/src/adapter/sqlite/sqlite.test.ts b/packages/core/src/adapter/sqlite/sqlite.test.ts index f3a225d8..5f45b986 100644 --- a/packages/core/src/adapter/sqlite/sqlite.test.ts +++ b/packages/core/src/adapter/sqlite/sqlite.test.ts @@ -15,6 +15,7 @@ import { idempotencyStoreContract, inboxLedgerContract, pendingEvent, + personalKeyStoreContract, readModelRebuildContract, readModelTransactionContract, schedulerContract, @@ -102,9 +103,10 @@ describe("the SQLite stores on node:sqlite", () => { inboxLedgerContract({ create: async () => (await storage()).inboxLedger }); deadLetterStoreContract({ create: async () => (await storage()).deadLetterStore }); schedulerContract({ create: async () => (await storage()).scheduler }); - idempotencyStoreContract({ create: async () => (await storage()).idempotencyStore }); - snapshotStoreContract({ create: async () => (await storage()).snapshotStore }); + idempotencyStoreContract({ create: storage }); + snapshotStoreContract({ create: storage }); storageTransactionContract({ create: storage }); + personalKeyStoreContract({ create: async () => (await storage()).personalKeys }); tableContract({ create: async () => ( @@ -314,6 +316,7 @@ describe("createSqliteAdapter", () => { "app_events", "app_idempotency_keys", "app_inbox", + "app_personal_keys", "app_rm_order_summary", "app_scheduled_commands", "app_snapshots", diff --git a/packages/core/src/adapter/staged-event-store.test.ts b/packages/core/src/adapter/staged-event-store.test.ts index 8f46c4c9..91319756 100644 --- a/packages/core/src/adapter/staged-event-store.test.ts +++ b/packages/core/src/adapter/staged-event-store.test.ts @@ -154,6 +154,29 @@ describe("createStagedEventStore", () => { ]); }); + it("hands out with each batch every person its appends were decided on", async () => { + const { staged } = await setUp(); + await staged.append({ + ...order, + expectedVersion: 1, + events: [pendingEvent({ aggregateId: "1", version: 2, type: "OrderPaid" })], + persons: ["c-1"], + }); + await staged.append({ + ...order, + expectedVersion: 2, + events: [pendingEvent({ aggregateId: "1", version: 3, type: "OrderArchived" })], + persons: ["c-2", "c-1"], + }); + const fresh = { aggregateType: "payment", aggregateId: "9" }; + await staged.append({ + ...fresh, + expectedVersion: 0, + events: [pendingEvent({ ...fresh, version: 1, type: "PaymentRequested" })], + }); + expect(staged.batches().map((batch) => batch.persons)).toEqual([["c-1", "c-2"], []]); + }); + it("reads the global stream from the base store alone", async () => { const { staged } = await setUp(); await staged.append({ diff --git a/packages/core/src/adapter/staged-event-store.ts b/packages/core/src/adapter/staged-event-store.ts index 408b090d..6f7aa943 100644 --- a/packages/core/src/adapter/staged-event-store.ts +++ b/packages/core/src/adapter/staged-event-store.ts @@ -38,6 +38,7 @@ interface StagedStream { readonly aggregateId: string; readonly base: number; readonly events: StoredEvent[]; + readonly persons: Set; } export const createStagedEventStore: CreateStagedEventStoreFunction = ( @@ -57,7 +58,13 @@ export const createStagedEventStore: CreateStagedEventStoreFunction = ( aggregateId, fromVersion: Number.MAX_SAFE_INTEGER, }); - const created = { aggregateType, aggregateId, base: version, events: [] }; + const created = { + aggregateType, + aggregateId, + base: version, + events: [], + persons: new Set(), + }; streams.set(key, created); return created; }; @@ -69,7 +76,13 @@ export const createStagedEventStore: CreateStagedEventStoreFunction = ( const staged = streams.get(key) ?? (() => { - const created = { aggregateType, aggregateId, base: stored.version, events: [] }; + const created = { + aggregateType, + aggregateId, + base: stored.version, + events: [], + persons: new Set(), + }; streams.set(key, created); return created; })(); @@ -81,7 +94,7 @@ export const createStagedEventStore: CreateStagedEventStoreFunction = ( version: staged.base + staged.events.length, }; }, - append: async ({ aggregateType, aggregateId, expectedVersion, events }) => { + append: async ({ aggregateType, aggregateId, expectedVersion, events, persons = [] }) => { const staged = await stagedFor(aggregateType, aggregateId); const actualVersion = staged.base + staged.events.length; if (actualVersion !== expectedVersion) { @@ -93,6 +106,7 @@ export const createStagedEventStore: CreateStagedEventStoreFunction = ( } const stored = events.map((event) => ({ ...event, position: nextPosition() })); staged.events.push(...stored); + for (const person of persons) staged.persons.add(person); return { version: actualVersion + stored.length, events: stored }; }, readAll: (args) => base.readAll(args), @@ -100,11 +114,12 @@ export const createStagedEventStore: CreateStagedEventStoreFunction = ( batches: () => [...streams.values()] .filter((staged) => staged.events.length > 0) - .map(({ aggregateType, aggregateId, base: expectedVersion, events }) => ({ + .map(({ aggregateType, aggregateId, base: expectedVersion, events, persons }) => ({ aggregateType, aggregateId, expectedVersion, events: events.map(({ position: _position, ...event }) => event), + persons: [...persons], positions: events.map((event) => event.position), })), }; diff --git a/packages/core/src/adapter/storage/event-store.ts b/packages/core/src/adapter/storage/event-store.ts index b633437c..fccd123a 100644 --- a/packages/core/src/adapter/storage/event-store.ts +++ b/packages/core/src/adapter/storage/event-store.ts @@ -14,6 +14,12 @@ export interface AppendArgs { */ readonly expectedVersion: number; readonly events: readonly PendingEvent[]; + /** + * The people whose personal data the state the events were decided on held. The kernel reads + * it before the store does, to refuse sealing their fields once they are forgotten; a store + * ignores it. + */ + readonly persons?: readonly string[]; } export interface AppendResult { diff --git a/packages/core/src/adapter/storage/idempotency-store.ts b/packages/core/src/adapter/storage/idempotency-store.ts index 0aecab50..01e13786 100644 --- a/packages/core/src/adapter/storage/idempotency-store.ts +++ b/packages/core/src/adapter/storage/idempotency-store.ts @@ -8,13 +8,16 @@ export type DispatchOutcome = DispatchResult | RejectedDispatch; /** * A dispatch remembered under its idempotency key: the fingerprint of the command it was, what it - * answered, and when the key may be used for another command. + * answered, and when the key may be used for another command. `persons` names, once each, the + * people whose personal fields were in the state a rejection was decided on, since its message + * may quote them; it is empty for any other outcome. */ export interface IdempotencyRecord { readonly key: string; readonly fingerprint: string; readonly outcome: DispatchOutcome; readonly expiresAt: string; + readonly persons: readonly string[]; } export interface GetIdempotencyRecordArgs { @@ -41,6 +44,10 @@ export interface PurgeIdempotencyRecordsArgs { * the same again. `put` must be atomic: of two dispatches racing for one key exactly one records * its outcome, and the other rejects with `IdempotencyKeyTakenError`, which rolls back whatever * the recording was part of. + * + * A record that names people lives only as long as their keys, as a snapshot does: `put` records + * nothing unless every person it names has a key in the storage's `personalKeys`, checked in the + * same statement, and `forget` removes every record that names a person. */ export interface IdempotencyStore { /** @@ -56,4 +63,5 @@ export interface IdempotencyStore { * Removes the records expired by `now`, and resolves to how many. */ purge(args: PurgeIdempotencyRecordsArgs): Promise; + forget(person: string): Promise; } 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/storage/snapshot-store.ts b/packages/core/src/adapter/storage/snapshot-store.ts index ca057102..f1dc28cb 100644 --- a/packages/core/src/adapter/storage/snapshot-store.ts +++ b/packages/core/src/adapter/storage/snapshot-store.ts @@ -2,21 +2,30 @@ import type { StreamIdentity } from "../../contracts/event.ts"; /** * The state of one aggregate stream folded up to `version`, as the kernel serialized it, valid - * only for the `fingerprint` of the code that folded it. + * only for the `fingerprint` of the code that folded it. `persons` names, once each, the people + * whose personal fields the events folded carried, since the state holds them in the clear. */ export interface Snapshot extends StreamIdentity { readonly version: number; readonly fingerprint: string; readonly state: string; + readonly persons: readonly string[]; } /** * One snapshot per stream, the last one `put`: a cache of the fold, never the source of truth, * so a kernel that finds one missing, stale or doubtful folds the stream from its first event * instead. `get` is `null` for a stream without one. `remove` forgets a stream's snapshot. + * + * A snapshot holds personal data in the clear, so it lives only as long as its persons' keys: + * `put` writes nothing unless every person it names has a key in the storage's `personalKeys`, + * checked in the same statement, and `forget` removes every snapshot that names a person. A + * transaction that removes a key and then forgets its snapshots leaves none behind, whatever + * `put` runs beside it. */ export interface SnapshotStore { get(stream: StreamIdentity): Promise; put(snapshot: Snapshot): Promise; remove(stream: StreamIdentity): Promise; + forget(person: string): Promise; } diff --git a/packages/core/src/adapter/testing/idempotency-store.contract.ts b/packages/core/src/adapter/testing/idempotency-store.contract.ts index 7ef74162..6a4e0739 100644 --- a/packages/core/src/adapter/testing/idempotency-store.contract.ts +++ b/packages/core/src/adapter/testing/idempotency-store.contract.ts @@ -1,10 +1,15 @@ import { beforeEach, describe, expect, it } from "vitest"; import { IdempotencyKeyTakenError } from "../../contracts/errors.ts"; +import type { Storage } from "../adapter.ts"; import type { IdempotencyStore } from "../storage/idempotency-store.ts"; +import type { PersonalKeyStore } from "../storage/personal-key-store.ts"; import { testOutcome } from "./fixtures.ts"; export interface IdempotencyStoreContractArgs { - readonly create: () => Promise; + /** + * Fresh, empty storage: the idempotency store and the personal keys its records depend on. + */ + readonly create: () => Promise>; } export interface IdempotencyStoreContractFunction { @@ -18,6 +23,7 @@ const record = (key: string, expiresInMs = 60_000) => ({ fingerprint: `fp-${key}`, outcome: testOutcome(key), expiresAt: later(expiresInMs).toISOString(), + persons: [], }); /** @@ -26,9 +32,47 @@ const record = (key: string, expiresInMs = 60_000) => ({ export const idempotencyStoreContract: IdempotencyStoreContractFunction = ({ create }) => { describe("idempotency store contract", () => { let store: IdempotencyStore; + let keys: PersonalKeyStore; beforeEach(async () => { - store = await create(); + ({ idempotencyStore: store, personalKeys: keys } = await create()); + }); + + const rejection = { + rejected: "Taken", + message: "ana@example.com is taken", + aggregateType: "customer", + aggregateId: "c-1", + }; + + it("records a record that names people only while each of them has a key", async () => { + const named = { ...record("k1"), outcome: rejection, persons: ["c-1", "c-2"], now }; + await keys.add("c-1", "k-1"); + await store.put(named); + expect(await store.get({ key: "k1", now })).toBeNull(); + await keys.add("c-2", "k-2"); + await store.put(named); + const { now: _now, ...kept } = named; + expect(await store.get({ key: "k1", now })).toEqual(kept); + await keys.remove("c-1"); + await expect(store.put({ ...named, fingerprint: "other" })).rejects.toBeInstanceOf( + IdempotencyKeyTakenError, + ); + }); + + it("forgets every record that names a person, and no other", async () => { + await keys.add("c-1", "k-1"); + await keys.add("c-2", "k-2"); + await store.put({ ...record("k1"), outcome: rejection, persons: ["c-1"], now }); + await store.put({ ...record("k2"), outcome: rejection, persons: ["c-2", "c-1"], now }); + await store.put({ ...record("k3"), outcome: rejection, persons: ["c-2"], now }); + await store.put({ ...record("k4"), now }); + await store.forget("c-1"); + await store.forget("nobody"); + const left = await Promise.all( + ["k1", "k2", "k3", "k4"].map(async (key) => (await store.get({ key, now }))?.key ?? null), + ); + expect(left).toEqual([null, null, "k3", "k4"]); }); it("answers a record as it was put, and null for a key it never saw", async () => { @@ -85,6 +129,7 @@ export const idempotencyStoreContract: IdempotencyStoreContractFunction = ({ cre expect(await store.get({ key: "k1", now: later(1_000) })).toMatchObject({ fingerprint: "again", expiresAt: later(5_000).toISOString(), + persons: [], }); await store.put({ ...record("k2", 2_000), now }); diff --git a/packages/core/src/adapter/testing/index.ts b/packages/core/src/adapter/testing/index.ts index 7e602a5b..025cc0cf 100644 --- a/packages/core/src/adapter/testing/index.ts +++ b/packages/core/src/adapter/testing/index.ts @@ -27,6 +27,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/snapshot-store.contract.ts b/packages/core/src/adapter/testing/snapshot-store.contract.ts index 3c9bacc0..fdeceadf 100644 --- a/packages/core/src/adapter/testing/snapshot-store.contract.ts +++ b/packages/core/src/adapter/testing/snapshot-store.contract.ts @@ -1,8 +1,13 @@ import { beforeEach, describe, expect, it } from "vitest"; +import type { Storage } from "../adapter.ts"; +import type { PersonalKeyStore } from "../storage/personal-key-store.ts"; import type { Snapshot, SnapshotStore } from "../storage/snapshot-store.ts"; export interface SnapshotStoreContractArgs { - readonly create: () => Promise; + /** + * Fresh, empty storage: the snapshot store and the personal keys its snapshots depend on. + */ + readonly create: () => Promise>; } export interface SnapshotStoreContractFunction { @@ -15,6 +20,7 @@ const order42: Snapshot = { version: 200, fingerprint: "abc", state: '[{"status":1,"total":2},"placed",19.5]', + persons: [], }; /** @@ -23,9 +29,10 @@ const order42: Snapshot = { export const snapshotStoreContract: SnapshotStoreContractFunction = ({ create }) => { describe("snapshot store contract", () => { let store: SnapshotStore; + let keys: PersonalKeyStore; beforeEach(async () => { - store = await create(); + ({ snapshotStore: store, personalKeys: keys } = await create()); }); it("has nothing for a stream never snapshotted", async () => { @@ -57,6 +64,45 @@ export const snapshotStoreContract: SnapshotStoreContractFunction = ({ create }) expect(await store.get({ aggregateType: "order", aggregateId: "42" })).toEqual(earlier); }); + it("writes a snapshot that names people only while each of them has a key", async () => { + const ada: Snapshot = { ...order42, persons: ["c-1", "c-2"] }; + await store.put(ada); + expect(await store.get(order42)).toBeNull(); + await keys.add("c-1", "k-1"); + await store.put(ada); + expect(await store.get(order42)).toBeNull(); + await keys.add("c-2", "k-2"); + await store.put(ada); + expect(await store.get(order42)).toEqual(ada); + await keys.remove("c-2"); + await store.put({ ...ada, version: 300 }); + expect(await store.get(order42)).toEqual(ada); + }); + + it("forgets every snapshot that names a person, and no other", async () => { + await keys.add("c-1", "k-1"); + await keys.add("c-2", "k-2"); + const named = (aggregateId: string, persons: readonly string[]): Snapshot => ({ + ...order42, + aggregateId, + persons, + }); + await store.put(named("1", ["c-1"])); + await store.put(named("2", ["c-2", "c-1"])); + await store.put(named("3", ["c-2"])); + await store.put(named("4", [])); + await store.put({ ...named("1", ["c-1"]), aggregateType: "customer" }); + await store.forget("c-1"); + await store.forget("nobody"); + const left = await Promise.all( + ["1", "2", "3", "4"].map((aggregateId) => + store.get({ aggregateType: "order", aggregateId }), + ), + ); + expect(left.map((snapshot) => snapshot?.persons ?? null)).toEqual([null, null, ["c-2"], []]); + expect(await store.get({ aggregateType: "customer", aggregateId: "1" })).toBeNull(); + }); + it("forgets the snapshot of the stream it is asked to remove, and only that one", async () => { await store.put(order42); await store.put({ ...order42, aggregateId: "43" }); diff --git a/packages/core/src/adapter/testing/storage-transaction.contract.ts b/packages/core/src/adapter/testing/storage-transaction.contract.ts index 7f4ca14d..c5a170e9 100644 --- a/packages/core/src/adapter/testing/storage-transaction.contract.ts +++ b/packages/core/src/adapter/testing/storage-transaction.contract.ts @@ -23,6 +23,7 @@ const record = { fingerprint: "fp", outcome: testOutcome("1"), expiresAt: new Date(now.getTime() + 60_000).toISOString(), + persons: [], now, }; @@ -130,6 +131,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"), @@ -145,6 +147,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 () => { @@ -258,6 +261,74 @@ 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("forgets a person's snapshots and remembered rejections with their key", async () => { + await storage.personalKeys.add("c-1", "k-1"); + await storage.personalKeys.add("c-2", "k-2"); + const snapshot = (aggregateId: string, persons: readonly string[]) => ({ + aggregateType: "customer", + aggregateId, + version: 1, + fingerprint: "fp", + state: "[]", + persons, + }); + await storage.snapshotStore.put(snapshot("c-1", ["c-1"])); + await storage.snapshotStore.put(snapshot("c-2", ["c-2"])); + const remembered = { + key: "req-1", + fingerprint: "fp", + outcome: { + rejected: "Taken", + message: "taken", + aggregateType: "customer", + aggregateId: "c-1", + }, + expiresAt: "2026-01-02T00:00:00.000Z", + persons: ["c-1"], + now, + }; + await storage.idempotencyStore.put(remembered); + await storage.transact(async (tx) => { + await tx.personalKeys.remove("c-1"); + await tx.snapshotStore.forget("c-1"); + await tx.idempotencyStore.forget("c-1"); + }); + expect(await storage.idempotencyStore.get({ key: "req-1", now })).toBeNull(); + expect(await storage.personalKeys.get("c-1")).toBeNull(); + expect(await storage.snapshotStore.get(snapshot("c-1", []))).toBeNull(); + expect(await storage.snapshotStore.get(snapshot("c-2", []))).toEqual( + snapshot("c-2", ["c-2"]), + ); + }); + 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/contracts/errors.ts b/packages/core/src/contracts/errors.ts index fbe88230..d1018307 100644 --- a/packages/core/src/contracts/errors.ts +++ b/packages/core/src/contracts/errors.ts @@ -56,8 +56,10 @@ export interface ConcurrencyErrorArgs { } /** - * Thrown when an append finds the stream at a version other than the one the handler decided on. - * A command is retried `runtime.commands.concurrencyRetries` times before it surfaces. + * Thrown when an append finds the stream at a version other than the one the handler decided on, + * or, with both versions equal, when a person whose personal data the handler decided on was + * forgotten since. A command is retried `runtime.commands.concurrencyRetries` times before it + * surfaces. */ export class ConcurrencyError extends BoundaError { readonly streamId: string; diff --git a/packages/core/src/kernel/aggregate/build-aggregates.ts b/packages/core/src/kernel/aggregate/build-aggregates.ts index 46ac07bd..c845402a 100644 --- a/packages/core/src/kernel/aggregate/build-aggregates.ts +++ b/packages/core/src/kernel/aggregate/build-aggregates.ts @@ -3,8 +3,10 @@ import type { ResolvedConfig } from "../../config/types.ts"; 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, @@ -14,9 +16,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`); } @@ -27,16 +32,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/load-aggregate.test.ts b/packages/core/src/kernel/aggregate/load-aggregate.test.ts index fd12fbb4..4802b64e 100644 --- a/packages/core/src/kernel/aggregate/load-aggregate.test.ts +++ b/packages/core/src/kernel/aggregate/load-aggregate.test.ts @@ -70,6 +70,7 @@ const setUp = async ({ get: vi.fn(storage.snapshotStore.get), put: vi.fn(storage.snapshotStore.put), remove: vi.fn(storage.snapshotStore.remove), + forget: vi.fn(storage.snapshotStore.forget), }; const { logger, entries } = createRecordingLogger(); const loader = createAggregateLoader({ snapshotStore, logger, strict }); @@ -88,6 +89,7 @@ const setUp = async ({ version: 2, fingerprint: "fp:1", state: serializeFold({ state: paid, created: true, openedWithout: null }), + persons: [], ...overrides, }); const load = (eventStore: EventStore = storage.eventStore) => @@ -105,6 +107,7 @@ describe("createAggregateLoader", () => { expect(await load()).toEqual({ folded: { state: paid, created: true, openedWithout: null }, version: 2, + persons: [], }); expect(snapshotStore.get).not.toHaveBeenCalled(); expect(outcomes()).toEqual([]); @@ -133,6 +136,7 @@ describe("createAggregateLoader", () => { expect(await load()).toEqual({ folded: { state: paid, created: true, openedWithout: null }, version: 3, + persons: [], }); expect(evolves.placed).not.toHaveBeenCalled(); expect(evolves.paid).not.toHaveBeenCalled(); @@ -217,6 +221,7 @@ describe("createAggregateLoader", () => { expect(await load(moving)).toEqual({ folded: { state: paid, created: true, openedWithout: null }, version: 2, + persons: [], }); expect(outcomes()).toEqual(["verified"]); }); @@ -234,6 +239,7 @@ describe("createAggregateLoader", () => { expect(await load()).toEqual({ folded: { state: paid, created: true, openedWithout: null }, version: 3, + persons: [], }); expect(evolves.placed).toHaveBeenCalledTimes(1); expect(outcomes()).toEqual(["mismatch"]); diff --git a/packages/core/src/kernel/aggregate/load-aggregate.ts b/packages/core/src/kernel/aggregate/load-aggregate.ts index b2d0b83f..26747c3c 100644 --- a/packages/core/src/kernel/aggregate/load-aggregate.ts +++ b/packages/core/src/kernel/aggregate/load-aggregate.ts @@ -3,6 +3,7 @@ import type { SnapshotStore } from "../../adapter/storage/snapshot-store.ts"; import { SnapshotError } from "../../contracts/errors.ts"; import type { StreamIdentity } from "../../contracts/event.ts"; import type { Logger } from "../../contracts/logger.ts"; +import { personsOf } from "../personal-data/persons.ts"; import { errorDetails } from "../shared/retry.ts"; import { ATTRIBUTES, METRICS, meter } from "../telemetry.ts"; import { type FoldedState, foldState } from "./fold-state.ts"; @@ -24,6 +25,10 @@ export interface LoadedAggregate { * The stream's head, system events included: what an append expects. */ readonly version: number; + /** + * Whose personal fields went into the state, as a snapshot of it records them. + */ + readonly persons: readonly string[]; } /** @@ -98,6 +103,7 @@ export const createAggregateLoader: CreateAggregateLoaderFunction = ({ return { folded: foldState({ aggregate, events }), version: Math.min(loaded.version, through), + persons: personsOf({ aggregate, events }), }; }; const dropped = async ( @@ -153,14 +159,15 @@ export const createAggregateLoader: CreateAggregateLoaderFunction = ({ ); } const folded = foldState({ aggregate, events: loaded.events, from }); + const persons = personsOf({ aggregate, events: loaded.events, from: snapshot.persons }); if (!due(aggregate, settings.verify)) { count(aggregate, "used"); - return { folded, version: loaded.version }; + return { folded, version: loaded.version, persons }; } const whole = await fromStart(aggregate, stream, eventStore, loaded.version); if (foldsEqual(folded, whole.folded)) { count(aggregate, "verified"); - return { folded, version: loaded.version }; + return { folded, version: loaded.version, persons }; } count(aggregate, "mismatch"); const message = `Snapshot of ${aggregate.name}:${aggregateId} at version ${snapshot.version} folds to a state at version ${loaded.version} that differs from the stream's; the fold is not a function of the events alone`; diff --git a/packages/core/src/kernel/aggregate/runtime.ts b/packages/core/src/kernel/aggregate/runtime.ts index 445c288c..8cb79963 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 864a1430..83f9f87d 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"; @@ -13,13 +13,14 @@ import { validateRegistry } from "../modules/validate.ts"; import type { AppEnv, AppRegistry, EnvSection } from "../register/index.ts"; import { buildAggregates } from "./aggregate/build-aggregates.ts"; import { createAggregateLoader } from "./aggregate/load-aggregate.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"; @@ -67,6 +68,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; } /** @@ -276,10 +286,7 @@ export const assembleApp: AssembleAppFunction = async ({ const written = await config.storage.createStorage({ logger }); opened.unshift(() => written.close()); const aggregates = buildAggregates({ registry, ports: ports.byAggregate, config }); - const storage = { - ...written, - eventStore: withUpcasting({ eventStore: written.eventStore, aggregates }), - }; + const storage = appStorage({ storage: written, aggregates }); const loader = createAggregateLoader({ snapshotStore: storage.snapshotStore, logger, @@ -506,6 +513,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/command/idempotency.test.ts b/packages/core/src/kernel/command/idempotency.test.ts index cd573187..2f8c9ddf 100644 --- a/packages/core/src/kernel/command/idempotency.test.ts +++ b/packages/core/src/kernel/command/idempotency.test.ts @@ -48,6 +48,7 @@ describe("a command dispatched with an idempotency key", () => { expect(await harness.storage.idempotencyStore.get({ key: "req-1", now })).toMatchObject({ outcome: first, expiresAt: "2026-01-02T00:00:00.000Z", + persons: [], }); expect(telemetry.counts.map((count) => count.attributes[ATTRIBUTES.outcome])).toEqual([ "stored", @@ -165,6 +166,7 @@ describe("a command dispatched with an idempotency key", () => { position: 7, }, expiresAt: "2026-01-02T00:00:00.000Z", + persons: [], now, }; // The winner records once this dispatch has looked the key up and found nothing. @@ -223,6 +225,7 @@ describe("a command dispatched with an idempotency key", () => { await harness.pipeline.dispatch(touch); expect(await harness.storage.idempotencyStore.get({ key: "req-1", now })).toMatchObject({ expiresAt: "2026-01-01T01:00:00.000Z", + persons: [], }); harness.clock.advance(3_600_000); await harness.pipeline.dispatch({ ...placeOrder, ...withKey("req-1") }); diff --git a/packages/core/src/kernel/command/pipeline.ts b/packages/core/src/kernel/command/pipeline.ts index af927618..d2977e43 100644 --- a/packages/core/src/kernel/command/pipeline.ts +++ b/packages/core/src/kernel/command/pipeline.ts @@ -321,6 +321,8 @@ export const createCommandPipeline: CreateCommandPipelineFunction = ({ }) => { // Only streams written before `begin` existed land here, so the set stays small. const warnedOpenings = new Set(); + // Whose personal data a rejection's state held, which its message may quote. + const rejectedPersons = new WeakMap(); const execute = async ( aggregate: AggregateRuntime, runtime: CommandRuntime, @@ -331,7 +333,7 @@ export const createCommandPipeline: CreateCommandPipelineFunction = ({ const attempts = config.runtime.commands.concurrencyRetries + 1; const { timeoutMs } = config.forAggregate(aggregate.name).commands; for (let attempt = 1; ; attempt += 1) { - const { folded, version } = await loader.load({ + const { folded, version, persons } = await loader.load({ aggregate, aggregateId: command.aggregateId, eventStore: store, @@ -370,6 +372,7 @@ export const createCommandPipeline: CreateCommandPipelineFunction = ({ for (const signal of signals) signal.throwIfAborted(); if (produced instanceof DomainError) { if (!issued.has(produced)) throw new ForeignRejectionError(command, produced); + rejectedPersons.set(produced, persons); return produced; } const events = toPendingEvents( @@ -390,6 +393,7 @@ export const createCommandPipeline: CreateCommandPipelineFunction = ({ aggregateId: command.aggregateId, expectedVersion: version, events, + persons, }); return storedDispatch(aggregate, command.aggregateId, appended); } catch (error) { @@ -490,12 +494,14 @@ export const createCommandPipeline: CreateCommandPipelineFunction = ({ const recordOf = ( { key, fingerprint }: Idempotency, outcome: DispatchOutcome, + persons: readonly string[] = [], ): PutIdempotencyRecordArgs => { const now = clock.now(); return { key, fingerprint, outcome, + persons, expiresAt: new Date( now.getTime() + config.runtime.commands.idempotencyKeyLifetimeMs, ).toISOString(), @@ -503,6 +509,9 @@ export const createCommandPipeline: CreateCommandPipelineFunction = ({ }; }; + const personsOf = (answer: DispatchResult | DomainError): readonly string[] => + answer instanceof DomainError ? (rejectedPersons.get(answer) ?? []) : []; + const outcomeOf = (answer: DispatchResult | DomainError): DispatchOutcome => answer instanceof DomainError ? { @@ -533,7 +542,9 @@ export const createCommandPipeline: CreateCommandPipelineFunction = ({ } if (within !== undefined) { const answer = await execute(aggregate, runtime, command, within.eventStore, signals); - await within.idempotencyStore.put(recordOf(idempotency, outcomeOf(answer))); + await within.idempotencyStore.put( + recordOf(idempotency, outcomeOf(answer), personsOf(answer)), + ); return answer; } let recorded = false; @@ -552,7 +563,10 @@ export const createCommandPipeline: CreateCommandPipelineFunction = ({ }, }; const answer = await execute(aggregate, runtime, command, recording, signals); - if (!recorded) await storage.idempotencyStore.put(recordOf(idempotency, outcomeOf(answer))); + if (!recorded) + await storage.idempotencyStore.put( + recordOf(idempotency, outcomeOf(answer), personsOf(answer)), + ); return answer; }; 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..57fb8dcc --- /dev/null +++ b/packages/core/src/kernel/personal-data/event-store.test.ts @@ -0,0 +1,352 @@ +import { describe, expect, it } from "vitest"; +import type { PersonalKeyStore } from "../../adapter/storage/personal-key-store.ts"; +import { pendingEvent } from "../../adapter/testing/fixtures.ts"; +import { ConcurrencyError, 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("refuses to give a new key to a person the events were decided on, forgotten since", async () => { + const { storage, store, calls, 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"); + calls.length = 0; + const refused = await store + .append({ + aggregateType: "customer", + aggregateId: "c-1", + expectedVersion: 1, + events: [registered(2, "ana@example.com")], + persons: ["c-2", "c-1"], + }) + .catch((error: unknown) => error); + expect(refused).toBeInstanceOf(ConcurrencyError); + expect(refused).toMatchObject({ + code: "CONCURRENCY_CONFLICT", + streamId: "customer:c-1", + expectedVersion: 1, + actualVersion: 1, + message: "Person c-1 was forgotten since customer:c-1 was loaded at version 1", + }); + expect(calls).toEqual(["get:c-1"]); + expect(await storage.personalKeys.get("c-1")).toBeNull(); + expect( + (await storage.eventStore.load({ aggregateType: "customer", aggregateId: "c-1" })).version, + ).toBe(1); + + await store.append({ + aggregateType: "customer", + aggregateId: "c-1", + expectedVersion: 1, + events: [registered(2, "ana@again.example")], + persons: ["c-2"], + }); + expect(await storage.personalKeys.get("c-1")).not.toBeNull(); + }); + + 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..62afa7df --- /dev/null +++ b/packages/core/src/kernel/personal-data/event-store.ts @@ -0,0 +1,149 @@ +import type { AppendArgs, EventStore, PendingEvent } from "../../adapter/storage/event-store.ts"; +import type { PersonalKeyStore } from "../../adapter/storage/personal-key-store.ts"; +import { ConcurrencyError, ValidationError } from "../../contracts/errors.ts"; +import { type StoredEvent, streamId } 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); +}; + +// A conflict like a moved stream: the events were decided on a state that held the person's data, +// and the person was forgotten since, so the command is decided again on the state without it. +class ForgottenSinceLoadError extends ConcurrencyError { + constructor(args: AppendArgs, person: string) { + const { expectedVersion } = args; + super({ streamId: streamId(args), expectedVersion, actualVersion: expectedVersion }); + this.message = `Person ${person} was forgotten since ${streamId(args)} was loaded at version ${expectedVersion}`; + } +} + +/** + * 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; + + // Only a person the state did not hold gets a new key: one it held and who has none now was + // forgotten after the load, and a new key would make their old data readable again. + const keyInForce = async (args: AppendArgs, person: string): Promise => { + const stored = await keys.get(person); + if (stored !== null) return importPersonalKey(stored); + if (args.persons?.includes(person)) throw new ForgottenSinceLoadError(args, person); + return importPersonalKey(await keys.add(person, await generatePersonalKey())); + }; + + 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(args, 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..d09ebb84 --- /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 { + // The key goes first: a snapshot written beside the transaction then either waits for it + // and is forgotten with the rest, or finds no key and is never written. + const { removed, stored } = await storage.transact(async (transaction) => { + const hadKey = await transaction.personalKeys.remove(personId); + await transaction.snapshotStore.forget(personId); + await transaction.idempotencyStore.forget(personId); + return { + removed: hadKey, + 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 }); + return { + scheduled: false, + aggregateType: BOUNDA_AGGREGATE, + aggregateId: personId, + version: stored.version, + eventIds: [stored.id], + eventTypes: [stored.type], + 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..a62555da --- /dev/null +++ b/packages/core/src/kernel/personal-data/personal-data.test.ts @@ -0,0 +1,398 @@ +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 { RejectFunction } from "../../modules/command.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[] = []; +// Runs inside `remindCustomer`'s handler once, between its load and its append. +let whileDeciding: (() => Promise) | null = null; + +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, + }, + customerReminded: { + payload: ({ z, personal }: EventPayloadArgs) => + z.object({ to: personal(z.email()).optional() }), + 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)], + }, + }, + confirmCustomer: { + module: { + payload: ({ z }: PayloadArgs) => z.object({ customerId: z.string() }), + rejections: ({ state }: { state: CustomerState }) => ({ + NotYet: `${state.email ?? "This customer"} cannot be confirmed yet`, + }), + handler: ({ reject }: { reject: RejectFunction<"NotYet"> }) => reject("NotYet"), + }, + }, + greetCustomer: { + module: { + payload: ({ z }: PayloadArgs) => z.object({ customerId: z.string() }), + handler: ({ + state, + events, + }: { + state: CustomerState; + events: Record unknown>; + }) => [events.customerGreeted?.({ to: state.email })], + }, + }, + remindCustomer: { + module: { + payload: ({ z }: PayloadArgs) => z.object({ customerId: z.string() }), + handler: async ({ + state, + events, + }: { + state: CustomerState; + events: Record unknown>; + }) => { + const run = whileDeciding; + whileDeciding = null; + await run?.(); + return [events.customerReminded?.(state.email === null ? {} : { 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("seals what a command dispatched with an idempotency key stores, and replays its answer", async () => { + const { app } = await createTestApp({ registry }); + const first = await app.commands.registerCustomer(ana, { idempotencyKey: "signup-1" }); + const again = await app.commands.registerCustomer(ana, { idempotencyKey: "signup-1" }); + expect(again).toEqual(first); + const events = await app.events.read(); + expect(events).toHaveLength(1); + expect(isSealed((events[0]?.payload as { email: unknown } | undefined)?.email)).toBe(true); + await app.stop(); + }); + + it("forgets a person's snapshots too, so their aggregate loads with the fields forgotten", async () => { + const adapter = memory(); + const storage = await adapter.createStorage({ logger: silentLogger }); + const { app } = await createTestApp({ + registry: { + ...registry, + aggregates: { + customer: { ...registry.aggregates.customer, stateFingerprint: "fp" }, + }, + }, + config: { runtime: { overrides: { customer: { snapshot: { every: 1 } } } } }, + adapter, + }); + await app.commands.registerCustomer(ana); + await app.runUntilIdle(); + const stream = { aggregateType: "customer", aggregateId: "c-1" }; + expect(await storage.snapshotStore.get(stream)).toMatchObject({ version: 1, persons: ["c-1"] }); + expect((await storage.snapshotStore.get(stream))?.state).toContain("ana@example.com"); + + await app.forget("c-1"); + expect(await storage.snapshotStore.get(stream)).toBeNull(); + const greeted = await app.commands.greetCustomer({ customerId: "c-1" }); + const [greeting] = await app.events.read({ after: greeted.position - 1 }); + expect(greeting?.payload).toEqual({ to: null }); + await app.runUntilIdle(); + expect(await storage.snapshotStore.get(stream)).toMatchObject({ version: 2, persons: [] }); + expect((await storage.snapshotStore.get(stream))?.state).not.toContain("ana@example.com"); + await app.stop(); + }); + + it("forgets the rejections it remembered for a person, so a retry decides again without their data", async () => { + const adapter = memory(); + const storage = await adapter.createStorage({ logger: silentLogger }); + const { app, clock } = await createTestApp({ registry, adapter }); + await app.commands.registerCustomer(ana); + const confirm = () => + app.commands + .confirmCustomer({ customerId: "c-1" }, { idempotencyKey: "confirm-1" }) + .catch((error: unknown) => error); + const first = await confirm(); + expect(first).toMatchObject({ + rejected: "NotYet", + message: "ana@example.com cannot be confirmed yet", + }); + expect( + await storage.idempotencyStore.get({ key: "confirm-1", now: clock.now() }), + ).toMatchObject({ + persons: ["c-1"], + }); + expect(await confirm()).toMatchObject({ message: "ana@example.com cannot be confirmed yet" }); + + await app.forget("c-1"); + expect(await storage.idempotencyStore.get({ key: "confirm-1", now: clock.now() })).toBeNull(); + const again = await confirm(); + expect(again).toMatchObject({ + rejected: "NotYet", + message: "This customer cannot be confirmed yet", + }); + expect( + JSON.stringify(await storage.idempotencyStore.get({ key: "confirm-1", now: clock.now() })), + ).not.toContain("ana@example.com"); + await app.stop(); + }); + + it("decides a command again when its person is forgotten between its load and its append", async () => { + const adapter = memory(); + const storage = await adapter.createStorage({ logger: silentLogger }); + const { app } = await createTestApp({ registry, adapter }); + await app.commands.registerCustomer(ana); + whileDeciding = () => app.forget("c-1"); + const reminded = await app.commands.remindCustomer({ customerId: "c-1" }); + expect(whileDeciding).toBeNull(); + expect(reminded.eventTypes).toEqual(["CustomerReminded"]); + const [reminder] = await app.events.read({ after: reminded.position - 1 }); + expect(reminder?.payload).toEqual({}); + expect(await storage.personalKeys.get("c-1")).toBeNull(); + expect(JSON.stringify(await app.events.read())).not.toContain("ana@example.com"); + 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/personal-data/persons.test.ts b/packages/core/src/kernel/personal-data/persons.test.ts new file mode 100644 index 00000000..01c923e1 --- /dev/null +++ b/packages/core/src/kernel/personal-data/persons.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { pendingEvent } from "../../adapter/testing/fixtures.ts"; +import type { StoredEvent } from "../../contracts/event.ts"; +import type { EventPayloadArgs } from "../../modules/payload.ts"; +import type { Registry } from "../../modules/registry.ts"; +import { buildAggregates } from "../aggregate/build-aggregates.ts"; +import { personsOf } from "./persons.ts"; + +const registry: Registry = { + aggregates: { + order: { + events: { + orderPlaced: { + payload: ({ z, personal, person }: EventPayloadArgs) => + z.object({ + customerId: person(z.string()), + note: personal(z.string()).optional(), + email: personal(z.string()).optional(), + }), + evolve: () => ({}), + }, + orderSigned: { + payload: ({ z, personal }: EventPayloadArgs) => + z.object({ signature: personal(z.string()) }), + evolve: () => ({}), + }, + orderPaid: { evolve: () => ({}) }, + }, + commands: {}, + policies: {}, + processes: {}, + }, + }, + readModels: {}, +}; + +const aggregate = buildAggregates({ registry, ports: {} }).byName.order; + +const event = (version: number, type: string, payload: unknown): StoredEvent => ({ + ...pendingEvent({ aggregateId: "o-1", version, type, payload }), + position: version, +}); + +describe("personsOf", () => { + it("names whoever an event's readable personal fields belong to, once each, after those known", () => { + if (aggregate === undefined) throw new Error("no aggregate"); + expect( + personsOf({ + aggregate, + from: ["c-0"], + events: [ + event(1, "OrderPlaced", { customerId: "c-1", note: "ring" }), + event(2, "OrderSigned", { signature: "x" }), + event(3, "OrderPlaced", { customerId: "c-1", email: "a@b.c" }), + event(4, "OrderPlaced", { customerId: 7, note: "n" }), + event(5, "OrderPaid", {}), + ], + }), + ).toEqual(["c-0", "c-1", "o-1", "7"]); + }); + + it("names no one for an event whose personal fields are forgotten or absent", () => { + if (aggregate === undefined) throw new Error("no aggregate"); + expect( + personsOf({ + aggregate, + events: [ + event(1, "OrderPlaced", { customerId: "c-1", note: null }), + event(2, "OrderPlaced", { customerId: "c-2" }), + event(3, "OrderSigned", { signature: null }), + event(4, "OrderPlaced", { customerId: { id: "c-3" }, note: "n" }), + event(5, "OrderPlaced", "not an object"), + event(6, "Unknown", { note: "n" }), + ], + }), + ).toEqual([]); + }); +}); diff --git a/packages/core/src/kernel/personal-data/persons.ts b/packages/core/src/kernel/personal-data/persons.ts new file mode 100644 index 00000000..898ebf6c --- /dev/null +++ b/packages/core/src/kernel/personal-data/persons.ts @@ -0,0 +1,35 @@ +import type { StoredEvent } from "../../contracts/event.ts"; +import type { AggregateRuntime } from "../aggregate/runtime.ts"; +import { isRecord } from "../shared/merge-fields.ts"; + +export interface PersonsOfArgs { + readonly aggregate: AggregateRuntime; + readonly events: readonly StoredEvent[]; + // Persons already known, such as a snapshot's, kept first. + readonly from?: readonly string[]; +} + +export interface PersonsOfFunction { + (args: PersonsOfArgs): readonly string[]; +} + +/** + * The people whose personal data the events carry, once each: whoever an event names, by its + * person field or its aggregate's id, when one of its marked fields still holds a value. A field + * forgotten reads as `null` and names no one, so a forgotten person's stream can be snapshotted + * again without them. + */ +export const personsOf: PersonsOfFunction = ({ aggregate, events, from = [] }) => { + const persons = new Set(from); + for (const event of events) { + const marks = aggregate.eventsByType[event.type]?.personal; + if (marks === undefined || marks === null || !isRecord(event.payload)) continue; + const payload = event.payload; + if (!marks.fields.some((field) => payload[field] !== undefined && payload[field] !== null)) { + continue; + } + const named = marks.person === null ? event.aggregateId : payload[marks.person]; + if (typeof named === "string" || typeof named === "number") persons.add(String(named)); + } + return [...persons]; +}; 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/scheduler/worker.test.ts b/packages/core/src/kernel/scheduler/worker.test.ts index ba0c6c4b..acf2f44c 100644 --- a/packages/core/src/kernel/scheduler/worker.test.ts +++ b/packages/core/src/kernel/scheduler/worker.test.ts @@ -63,6 +63,7 @@ describe("scheduled command worker", () => { fingerprint: "fp", outcome: { rejected: "Nope", message: "no", aggregateType: "order", aggregateId: "1" }, expiresAt: new Date(harness.clock.now().getTime() + expiresInMs).toISOString(), + persons: [], now: harness.clock.now(), }); await idempotencyStore.put(record("gone", -1)); diff --git a/packages/core/src/kernel/snapshot/subscriber.test.ts b/packages/core/src/kernel/snapshot/subscriber.test.ts index ada72dac..a04ddcac 100644 --- a/packages/core/src/kernel/snapshot/subscriber.test.ts +++ b/packages/core/src/kernel/snapshot/subscriber.test.ts @@ -135,6 +135,7 @@ describe("createSnapshotSubscriber", () => { expect(await stored("1")).toMatchObject({ version: 4 }); await storage.snapshotStore.put({ aggregateType: "order", + persons: [], aggregateId: "1", version: 4, fingerprint: "fp:2", @@ -206,6 +207,7 @@ describe("createSnapshotSubscriber", () => { ); await storage.snapshotStore.put({ aggregateType: "order", + persons: [], aggregateId: "1", version: 2, fingerprint: "fp:1", diff --git a/packages/core/src/kernel/snapshot/subscriber.ts b/packages/core/src/kernel/snapshot/subscriber.ts index bead4389..03674d9b 100644 --- a/packages/core/src/kernel/snapshot/subscriber.ts +++ b/packages/core/src/kernel/snapshot/subscriber.ts @@ -65,7 +65,7 @@ export const createSnapshotSubscriber: CreateSnapshotSubscriberFunction = ({ const snapshot = async (aggregateId: string): Promise => { const stream = { aggregateType: aggregate.name, aggregateId }; - const { folded, version } = await loader.load({ aggregate, aggregateId, eventStore }); + const { folded, version, persons } = await loader.load({ aggregate, aggregateId, eventStore }); let state: string; try { state = serializeFold(folded); @@ -80,7 +80,7 @@ export const createSnapshotSubscriber: CreateSnapshotSubscriberFunction = ({ logger.error("aggregate state cannot be snapshotted", { ...stream, version, ...details }); return; } - await snapshotStore.put({ ...stream, version, fingerprint, state }); + await snapshotStore.put({ ...stream, version, fingerprint, state, persons }); logger.debug("snapshot written", { ...stream, version }); }; 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 84bf2960..7e110ea2 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 @@ -21,7 +21,7 @@ export type UnitStores = Pick< * store as it is, plus the unit's own events; `add` answers the letter as filed, whatever the * store holds under that id already. */ -export interface UnitOfWork extends StorageTransaction { +export interface UnitOfWork extends Omit { 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/idempotency-store.ts b/packages/core/src/memory/idempotency-store.ts index c0550dd9..18cca8bf 100644 --- a/packages/core/src/memory/idempotency-store.ts +++ b/packages/core/src/memory/idempotency-store.ts @@ -7,8 +7,15 @@ export interface CreateMemoryIdempotencyStoreFunction { (): IdempotencyStore; } +export interface CreateKeptIdempotencyStoreArgs { + /** + * Whether a person has a personal key; synchronous, so `put` checks and writes in one step. + */ + readonly holds: (person: string) => boolean; +} + export interface CreateKeptIdempotencyStoreFunction { - (): WithEntries; + (args: CreateKeptIdempotencyStoreArgs): WithEntries; } const expired = (record: IdempotencyRecord, now: Date): boolean => @@ -19,7 +26,7 @@ const copy = (record: IdempotencyRecord): IdempotencyRecord => ({ outcome: jsonCopy(record.outcome), }); -export const createKeptIdempotencyStore: CreateKeptIdempotencyStoreFunction = () => { +export const createKeptIdempotencyStore: CreateKeptIdempotencyStoreFunction = ({ holds }) => { const records = new Map(); const store: IdempotencyStore = { @@ -32,7 +39,7 @@ export const createKeptIdempotencyStore: CreateKeptIdempotencyStoreFunction = () if (existing !== undefined && !expired(existing, now)) { throw new IdempotencyKeyTakenError(record.key); } - records.set(record.key, copy(record)); + if (record.persons.every(holds)) records.set(record.key, copy(record)); }, purge: async ({ now }) => { let purged = 0; @@ -43,9 +50,14 @@ export const createKeptIdempotencyStore: CreateKeptIdempotencyStoreFunction = () } return purged; }, + forget: async (person) => { + for (const [key, record] of records) { + if (record.persons.includes(person)) records.delete(key); + } + }, }; return { store, entries: createStoreEntries(records, (key: string) => key) }; }; export const createMemoryIdempotencyStore: CreateMemoryIdempotencyStoreFunction = () => - createKeptIdempotencyStore().store; + createKeptIdempotencyStore({ holds: () => true }).store; diff --git a/packages/core/src/memory/index.ts b/packages/core/src/memory/index.ts index 4ff399cc..957bde99 100644 --- a/packages/core/src/memory/index.ts +++ b/packages/core/src/memory/index.ts @@ -17,6 +17,7 @@ import { createMemoryEventNotifier } from "./event-notifier.ts"; import { createMemoryEventStore } from "./event-store.ts"; import { createKeptIdempotencyStore } from "./idempotency-store.ts"; import { createKeptInboxLedger } from "./inbox-ledger.ts"; +import { createKeptPersonalKeyStore } from "./personal-key-store.ts"; import { createKeptScheduler } from "./scheduler.ts"; import { createMemorySnapshotStore } from "./snapshot-store.ts"; import { createMemoryReadClient, createMemoryTable, type MemoryTable } from "./table.ts"; @@ -102,19 +103,23 @@ export const memory: MemoryFunction = (options = {}) => { const inboxLedger = createKeptInboxLedger(); const deadLetterStore = createKeptDeadLetterStore(); const scheduler = createKeptScheduler(); - const idempotencyStore = createKeptIdempotencyStore(); + const personalKeys = createKeptPersonalKeyStore(); + const idempotencyStore = createKeptIdempotencyStore({ holds: personalKeys.holds }); + const snapshotStore = createMemorySnapshotStore({ holds: personalKeys.holds }); const stores = { eventStore: createMemoryEventStore({ onAppend: notifier.notify }), inboxLedger: inboxLedger.store, deadLetterStore: deadLetterStore.store, scheduler: scheduler.store, idempotencyStore: idempotencyStore.store, + personalKeys: personalKeys.store, + snapshotStore, }; storage ??= { ...stores, notifier, checkpointStore, - snapshotStore: createMemorySnapshotStore(), + snapshotStore, transact: createMemoryStorageTransaction({ ...stores, entries: { @@ -122,6 +127,7 @@ export const memory: MemoryFunction = (options = {}) => { deadLetterStore: deadLetterStore.entries, scheduler: scheduler.entries, idempotencyStore: idempotencyStore.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 94076849..cfcfb55c 100644 --- a/packages/core/src/memory/memory.test.ts +++ b/packages/core/src/memory/memory.test.ts @@ -9,6 +9,7 @@ import { idempotencyStoreContract, inboxLedgerContract, pendingEvent, + personalKeyStoreContract, readModelRebuildContract, readModelTransactionContract, schedulerContract, @@ -31,9 +32,10 @@ describe("memory adapter", () => { inboxLedgerContract({ create: async () => (await storage()).inboxLedger }); deadLetterStoreContract({ create: async () => (await storage()).deadLetterStore }); schedulerContract({ create: async () => (await storage()).scheduler }); - idempotencyStoreContract({ create: async () => (await storage()).idempotencyStore }); - snapshotStoreContract({ create: async () => (await storage()).snapshotStore }); + idempotencyStoreContract({ create: storage }); + snapshotStoreContract({ create: storage }); storageTransactionContract({ create: storage }); + personalKeyStoreContract({ create: async () => (await storage()).personalKeys }); tableContract({ create: async () => { const model = await memory().createReadModel({ @@ -327,6 +329,7 @@ describe("memory adapter", () => { fingerprint: "fp", outcome: { rejected: "Nope", message: "no", aggregateType: "order", aggregateId: "1" }, expiresAt: "2026-01-02T00:00:00.000Z", + persons: [], now, }); await tx.inboxLedger.fail({ ...key, error: "boom" }); 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..b73a91f3 --- /dev/null +++ b/packages/core/src/memory/personal-key-store.ts @@ -0,0 +1,39 @@ +import type { PersonalKeyStore } from "../adapter/storage/personal-key-store.ts"; +import { createStoreEntries, type WithEntries } from "./entries.ts"; + +export interface KeptPersonalKeyStore extends WithEntries { + /** + * Whether `person` has a key, answered at once: the memory snapshot store checks it in the + * same step as it writes, so no forget runs between the two. + */ + holds(person: string): boolean; +} + +export interface CreateKeptPersonalKeyStoreFunction { + (): KeptPersonalKeyStore; +} + +export const createKeptPersonalKeyStore: CreateKeptPersonalKeyStoreFunction = () => { + const keys = new Map(); + return { + holds: (person) => keys.has(person), + 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/snapshot-store.ts b/packages/core/src/memory/snapshot-store.ts index 5f7e5fdb..8ff9881f 100644 --- a/packages/core/src/memory/snapshot-store.ts +++ b/packages/core/src/memory/snapshot-store.ts @@ -1,19 +1,31 @@ import type { Snapshot, SnapshotStore } from "../adapter/storage/snapshot-store.ts"; import { streamId } from "../contracts/event.ts"; +export interface CreateMemorySnapshotStoreArgs { + /** + * Whether a person has a personal key; synchronous, so `put` checks and writes in one step. + */ + readonly holds: (person: string) => boolean; +} + export interface CreateMemorySnapshotStoreFunction { - (): SnapshotStore; + (args: CreateMemorySnapshotStoreArgs): SnapshotStore; } -export const createMemorySnapshotStore: CreateMemorySnapshotStoreFunction = () => { +export const createMemorySnapshotStore: CreateMemorySnapshotStoreFunction = ({ holds }) => { const snapshots = new Map(); return { get: async (stream) => snapshots.get(streamId(stream)) ?? null, put: async (snapshot) => { - snapshots.set(streamId(snapshot), snapshot); + if (snapshot.persons.every(holds)) snapshots.set(streamId(snapshot), snapshot); }, remove: async (stream) => { snapshots.delete(streamId(stream)); }, + forget: async (person) => { + for (const [key, snapshot] of snapshots) { + if (snapshot.persons.includes(person)) snapshots.delete(key); + } + }, }; }; diff --git a/packages/core/src/memory/transaction.ts b/packages/core/src/memory/transaction.ts index 4a042f25..997afe92 100644 --- a/packages/core/src/memory/transaction.ts +++ b/packages/core/src/memory/transaction.ts @@ -5,7 +5,9 @@ import type { CheckpointStore } from "../adapter/storage/checkpoint-store.ts"; import type { DeadLetterStore } from "../adapter/storage/dead-letter-store.ts"; import type { IdempotencyStore } from "../adapter/storage/idempotency-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 { SnapshotStore } from "../adapter/storage/snapshot-store.ts"; import type { StoreEntries } from "./entries.ts"; import type { MemoryEventStore } from "./event-store.ts"; @@ -15,11 +17,14 @@ export interface CreateMemoryStorageTransactionArgs { readonly deadLetterStore: DeadLetterStore; readonly scheduler: Scheduler; readonly idempotencyStore: IdempotencyStore; + readonly personalKeys: PersonalKeyStore; + readonly snapshotStore: SnapshotStore; readonly entries: { readonly inboxLedger: StoreEntries; readonly deadLetterStore: StoreEntries; readonly scheduler: StoreEntries; readonly idempotencyStore: StoreEntries; + readonly personalKeys: StoreEntries; }; } @@ -27,22 +32,21 @@ export interface CreateMemoryStorageTransactionFunction { (args: CreateMemoryStorageTransactionArgs): Storage["transact"]; } -type CreateUndoLogArgs = Omit; +type CreateUndoLogArgs = Omit< + CreateMemoryStorageTransactionArgs, + "eventStore" | "personalKeys" | "snapshotStore" +>; interface UndoLog { readonly target: DeferredWriteTarget; undo(): void; } -const createUndoLog = ({ - inboxLedger, - deadLetterStore, - scheduler, - idempotencyStore, - entries, -}: CreateUndoLogArgs): UndoLog => { - const undos: (() => void)[] = []; - const tracked = ( +type Undos = (() => void)[]; + +const trackedBy = + (undos: Undos) => + ( store: StoreEntries, key: NoInfer, write: () => Promise, @@ -55,6 +59,35 @@ 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, + idempotencyStore, + entries, +}: CreateUndoLogArgs): UndoLog => { + const undos: Undos = []; + const tracked = trackedBy(undos); return { target: { inboxLedger: { @@ -83,11 +116,11 @@ const createUndoLog = ({ idempotencyStore: { put: (args) => tracked(entries.idempotencyStore, args.key, () => idempotencyStore.put(args)), + // Not put back: a rejection forgotten is decided again, which is all it costs. + forget: (person) => idempotencyStore.forget(person), }, }, - undo: () => { - for (const undo of undos.splice(0).reverse()) undo(); - }, + undo: () => undoAll(undos), }; }; @@ -97,12 +130,16 @@ const createUndoLog = ({ * its appends will commit with; transactions then commit one at a time, every stream appended * in one synchronous run, so no reader sees one stream appended without the others, nor an * event written meanwhile past a position still reserved. A failed transaction appends nothing, - * gives its positions back and puts back each entry it changed, unless someone changed it since. + * gives its positions back and puts back each entry it changed, unless someone changed it since; + * its personal keys are written at once and put back the same way, and the snapshots it forgets + * are gone at once, for good, as a cache can be. * `tryClaim`, `claimDue`, both `renew`, `purge` and writes made on the stores themselves do not * wait for a commit, so they can see a write it may still put back. */ export const createMemoryStorageTransaction: CreateMemoryStorageTransactionFunction = ({ eventStore, + personalKeys, + snapshotStore, ...live }) => { const commits = createMemoryLocks(); @@ -116,8 +153,14 @@ export const createMemoryStorageTransaction: CreateMemoryStorageTransactionFunct }, }); const { stores, flush } = deferWrites(live); + const keyUndos: Undos = []; try { - const result = await work({ eventStore: staged, ...stores }); + const result = await work({ + eventStore: staged, + ...stores, + personalKeys: trackedPersonalKeys(personalKeys, live.entries.personalKeys, keyUndos), + snapshotStore, + }); // 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); @@ -131,9 +174,11 @@ export const createMemoryStorageTransaction: CreateMemoryStorageTransactionFunct release?.(); } reserved.length = 0; + keyUndos.length = 0; return result; } finally { eventStore.release(reserved); + undoAll(keyUndos); } }; }; 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 3519c4e6..3dd5ed9b 100644 --- a/packages/core/src/modules/validate.test.ts +++ b/packages/core/src/modules/validate.test.ts @@ -309,6 +309,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 18c023a9..f8edaa7a 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' }); } @@ -133,6 +140,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, @@ -142,19 +161,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 2d33c8a7..2606d388 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"; @@ -41,7 +47,7 @@ export const registry = { aggregates: { customer: { state: customerState, - stateFingerprint: "c452b414dc30e1d1", + stateFingerprint: "a35f216828655fff", events: { customerRegistered }, commands: { registerCustomer: { module: registerCustomer } }, policies: {}, @@ -49,7 +55,7 @@ export const registry = { }, order: { state: orderState, - stateFingerprint: "844a21009adced43", + stateFingerprint: "6c9864790b9c4e6d", events: { orderCancelled, orderPaid, orderPlaced }, upcasts: { orderPlaced: orderPlacedUpcasts }, ports: { @@ -69,7 +75,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, @@ -80,6 +86,11 @@ export const registry = { }, }, readModels: { + customerDirectory: { + view: customerDirectoryView, + projections: { bounda: { personalDataForgotten: customerDirectoryOnBoundaPersonalDataForgotten }, customer: { customerRegistered: customerDirectoryOnCustomerCustomerRegistered } }, + queries: { getCustomer }, + }, orderSummary: { view: orderSummaryView, ports: { @@ -87,7 +98,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 1a747945..6639f3b0 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"; @@ -416,10 +431,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"); }); @@ -444,7 +544,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(); @@ -454,7 +554,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; }), ]; @@ -463,7 +563,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/idempotency-store.ts b/packages/postgresql/src/idempotency-store.ts index d74ebaeb..84362277 100644 --- a/packages/postgresql/src/idempotency-store.ts +++ b/packages/postgresql/src/idempotency-store.ts @@ -10,6 +10,8 @@ import type { PostgresqlDatabase } from "./database.ts"; export interface CreatePostgresqlIdempotencyStoreArgs { readonly db: PostgresqlDatabase; readonly table: string; + // The personal keys a record's persons must hold for `put` to write it. + readonly personalKeys: string; } export interface CreatePostgresqlIdempotencyStoreFunction { @@ -21,40 +23,59 @@ const toRecord = (row: Record): IdempotencyRecord => ({ fingerprint: String(row.fingerprint), outcome: row.outcome as DispatchOutcome, expiresAt: String(row.expires_at), + persons: (row.persons as readonly string[]).map(String), }); /** * `put` is a single upsert: PostgreSQL locks the conflicting row, so of two dispatches racing for - * one key exactly one gets its row back. + * one key exactly one gets its row back. It locks the keys of the persons the record names too, + * as a snapshot's `put` does, so a forget beside it waits and then removes it, or removed a key + * first and leaves it nothing to record; no row back with no live record under the key is that. */ export const createPostgresqlIdempotencyStore: CreatePostgresqlIdempotencyStoreFunction = ({ db, table, -}) => ({ - get: async ({ key, now }) => { + personalKeys, +}) => { + const get: IdempotencyStore["get"] = async ({ key, now }) => { const [row] = await db.all( - `SELECT "key", "fingerprint", "outcome", "expires_at" FROM ${table} WHERE "key" = $1 AND "expires_at" > $2`, + `SELECT "key", "fingerprint", "outcome", "expires_at", "persons" FROM ${table} WHERE "key" = $1 AND "expires_at" > $2`, [key, now.toISOString()], ); return row === undefined ? null : toRecord(row); - }, - put: async ({ key, fingerprint, outcome, expiresAt, now }) => { - const rows = await db.all( - `INSERT INTO ${table} ("key", "fingerprint", "outcome", "expires_at") VALUES ($1, $2, $3, $4) + }; + return { + get, + put: async ({ key, fingerprint, outcome, expiresAt, now, persons }) => { + const rows = await db.all( + `INSERT INTO ${table} ("key", "fingerprint", "outcome", "expires_at", "persons") + SELECT $1, $2, $3, $4, $6::text[] WHERE (SELECT count(*) FROM (SELECT 1 FROM ${personalKeys} WHERE "person" = ANY($6::text[]) FOR SHARE) AS "held") = cardinality($6::text[]) ON CONFLICT ("key") DO UPDATE SET "fingerprint" = excluded."fingerprint", "outcome" = excluded."outcome", - "expires_at" = excluded."expires_at" + "expires_at" = excluded."expires_at", + "persons" = excluded."persons" WHERE ${table}."expires_at" <= $5 RETURNING "key"`, - [key, fingerprint, postgresqlDialect.encode("json", outcome), expiresAt, now.toISOString()], - ); - if (rows.length === 0) throw new IdempotencyKeyTakenError(key); - }, - purge: async ({ now }) => { - const rows = await db.all(`DELETE FROM ${table} WHERE "expires_at" <= $1 RETURNING "key"`, [ - now.toISOString(), - ]); - return rows.length; - }, -}); + [ + key, + fingerprint, + postgresqlDialect.encode("json", outcome), + expiresAt, + now.toISOString(), + [...persons], + ], + ); + if (rows.length === 0 && (await get({ key, now })) !== null) { + throw new IdempotencyKeyTakenError(key); + } + }, + purge: async ({ now }) => { + const rows = await db.all(`DELETE FROM ${table} WHERE "expires_at" <= $1 RETURNING "key"`, [ + now.toISOString(), + ]); + return rows.length; + }, + forget: (person) => db.run(`DELETE FROM ${table} WHERE $1 = ANY("persons")`, [person]), + }; +}; diff --git a/packages/postgresql/src/index.ts b/packages/postgresql/src/index.ts index 63d8dadd..c1a378be 100644 --- a/packages/postgresql/src/index.ts +++ b/packages/postgresql/src/index.ts @@ -14,6 +14,7 @@ import { createPostgresqlEventStore } from "./event-store.ts"; import { createPostgresqlIdempotencyStore } from "./idempotency-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"; @@ -107,6 +108,16 @@ export const postgresql: PostgresqlFunction = (options) => { idempotencyStore: createPostgresqlIdempotencyStore({ db: database, table: tables.idempotencyKeys, + personalKeys: tables.personalKeys, + }), + personalKeys: createPostgresqlPersonalKeyStore({ + db: database, + table: tables.personalKeys, + }), + snapshotStore: createPostgresqlSnapshotStore({ + db: database, + table: tables.snapshots, + personalKeys: tables.personalKeys, }), }); const boundTo = (tx: SqlTransaction): PostgresqlDatabase => ({ @@ -118,7 +129,6 @@ export const postgresql: PostgresqlFunction = (options) => { ...storesOver(db), notifier: createPostgresqlEventNotifier({ sql, channel: tables.channel }), checkpointStore: createPostgresqlCheckpointStore({ db, table: tables.checkpoints }), - snapshotStore: createPostgresqlSnapshotStore({ db, table: tables.snapshots }), // The append lock comes first, before any row the work may lock: two transactions that // took the lock and a row in opposite orders would deadlock. transact: (work) => diff --git a/packages/postgresql/src/personal-key-store.ts b/packages/postgresql/src/personal-key-store.ts new file mode 100644 index 00000000..72f4ba3d --- /dev/null +++ b/packages/postgresql/src/personal-key-store.ts @@ -0,0 +1,35 @@ +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, + // Whichever writer inserted first, every one answers with the key in force. + add: async (person, key) => { + await db.run( + `INSERT INTO ${table} ("person", "key") VALUES ($1, $2) ON CONFLICT ("person") DO NOTHING`, + [person, key], + ); + return (await get(person)) ?? 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 6f44a263..89a3a2f0 100644 --- a/packages/postgresql/src/postgresql.test.ts +++ b/packages/postgresql/src/postgresql.test.ts @@ -20,6 +20,7 @@ import { idempotencyStoreContract, inboxLedgerContract, pendingEvent, + personalKeyStoreContract, readModelRebuildContract, readModelTransactionContract, schedulerContract, @@ -97,9 +98,10 @@ describe.skipIf(container === null)("postgresql adapter", () => { inboxLedgerContract({ create: async () => (await openStorage()).inboxLedger }); deadLetterStoreContract({ create: async () => (await openStorage()).deadLetterStore }); schedulerContract({ create: async () => (await openStorage()).scheduler }); - idempotencyStoreContract({ create: async () => (await openStorage()).idempotencyStore }); - snapshotStoreContract({ create: async () => (await openStorage()).snapshotStore }); + idempotencyStoreContract({ create: () => openStorage() }); + snapshotStoreContract({ create: () => openStorage() }); 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(); @@ -446,6 +448,7 @@ describe.skipIf(container === null)("postgresql adapter", () => { "events", "idempotency_keys", "inbox", + "personal_keys", "rm_order_summary", "scheduled_commands", "snapshots", diff --git a/packages/postgresql/src/schema.ts b/packages/postgresql/src/schema.ts index ed11777a..67de6952 100644 --- a/packages/postgresql/src/schema.ts +++ b/packages/postgresql/src/schema.ts @@ -14,6 +14,7 @@ export interface StorageTables { readonly deadLetters: string; readonly scheduledCommands: string; readonly idempotencyKeys: string; + readonly personalKeys: string; readonly snapshots: string; readonly appendLockKey: string; // The first key of the advisory locks read models take on the checkpoints table. @@ -54,6 +55,7 @@ export const storageTablesFor: StorageTablesForFunction = ({ prefix, schema }) = deadLetters: quoteIdentifier(storageTableNameFor({ prefix, table: "deadLetters" })), scheduledCommands: quoteIdentifier(storageTableNameFor({ prefix, table: "scheduledCommands" })), idempotencyKeys: quoteIdentifier(storageTableNameFor({ prefix, table: "idempotencyKeys" })), + personalKeys: quoteIdentifier(storageTableNameFor({ prefix, table: "personalKeys" })), snapshots: quoteIdentifier(storageTableNameFor({ prefix, table: "snapshots" })), appendLockKey: `bounda:${schema}.${events}`, checkpointsLockKey: `bounda:${schema}.${checkpoints}`, @@ -139,15 +141,21 @@ export const storageSchemaStatements: StorageSchemaStatementsFunction = (tables) "key" text PRIMARY KEY, "fingerprint" text NOT NULL, "outcome" jsonb NOT NULL, - "expires_at" text NOT NULL + "expires_at" text NOT NULL, + "persons" text[] NOT NULL )`, `CREATE INDEX IF NOT EXISTS ${indexName(tables.idempotencyKeys, "expires_at")} ON ${tables.idempotencyKeys} ("expires_at")`, + `CREATE TABLE IF NOT EXISTS ${tables.personalKeys} ( + "person" text PRIMARY KEY, + "key" text NOT NULL + )`, `CREATE TABLE IF NOT EXISTS ${tables.snapshots} ( "aggregate_type" text NOT NULL, "aggregate_id" text NOT NULL, "version" integer NOT NULL, "fingerprint" text NOT NULL, "state" text NOT NULL, + "persons" text[] NOT NULL, PRIMARY KEY ("aggregate_type", "aggregate_id") )`, ]; @@ -176,6 +184,7 @@ export const ensureStorageSchema: EnsureStorageSchemaFunction = async ({ db, sch tables.deadLetters, tables.scheduledCommands, tables.idempotencyKeys, + tables.personalKeys, tables.snapshots, indexName(tables.deadLetters, "status"), indexName(tables.scheduledCommands, "execute_at"), diff --git a/packages/postgresql/src/snapshot-store.ts b/packages/postgresql/src/snapshot-store.ts index 2c8c3c00..502bdf05 100644 --- a/packages/postgresql/src/snapshot-store.ts +++ b/packages/postgresql/src/snapshot-store.ts @@ -4,6 +4,8 @@ import type { SqlExecutor } from "@bounda-dev/core/adapter/sql"; export interface CreatePostgresqlSnapshotStoreArgs { readonly db: SqlExecutor; readonly table: string; + // The personal keys a snapshot's persons must hold for `put` to write it. + readonly personalKeys: string; } export interface CreatePostgresqlSnapshotStoreFunction { @@ -16,27 +18,32 @@ const toSnapshot = (row: Record): Snapshot => ({ version: Number(row.version), fingerprint: String(row.fingerprint), state: String(row.state), + persons: (row.persons as readonly string[]).map(String), }); export const createPostgresqlSnapshotStore: CreatePostgresqlSnapshotStoreFunction = ({ db, table, + personalKeys, }) => ({ get: async ({ aggregateType, aggregateId }) => { const [row] = await db.all( - `SELECT "aggregate_type", "aggregate_id", "version", "fingerprint", "state" FROM ${table} WHERE "aggregate_type" = $1 AND "aggregate_id" = $2`, + `SELECT "aggregate_type", "aggregate_id", "version", "fingerprint", "state", "persons" FROM ${table} WHERE "aggregate_type" = $1 AND "aggregate_id" = $2`, [aggregateType, aggregateId], ); return row === undefined ? null : toSnapshot(row); }, - put: ({ aggregateType, aggregateId, version, fingerprint, state }) => + // The keys are locked until the write commits: a forget that removes one waits for it, and + // then finds the snapshot to remove; one that removed it first leaves the write nothing. + put: ({ aggregateType, aggregateId, version, fingerprint, state, persons }) => db.run( - `INSERT INTO ${table} ("aggregate_type", "aggregate_id", "version", "fingerprint", "state") VALUES ($1, $2, $3, $4, $5) ON CONFLICT ("aggregate_type", "aggregate_id") DO UPDATE SET "version" = excluded."version", "fingerprint" = excluded."fingerprint", "state" = excluded."state"`, - [aggregateType, aggregateId, version, fingerprint, state], + `INSERT INTO ${table} ("aggregate_type", "aggregate_id", "version", "fingerprint", "state", "persons") SELECT $1, $2, $3, $4, $5, $6::text[] WHERE (SELECT count(*) FROM (SELECT 1 FROM ${personalKeys} WHERE "person" = ANY($6::text[]) FOR SHARE) AS "held") = cardinality($6::text[]) ON CONFLICT ("aggregate_type", "aggregate_id") DO UPDATE SET "version" = excluded."version", "fingerprint" = excluded."fingerprint", "state" = excluded."state", "persons" = excluded."persons"`, + [aggregateType, aggregateId, version, fingerprint, state, [...persons]], ), remove: ({ aggregateType, aggregateId }) => db.run(`DELETE FROM ${table} WHERE "aggregate_type" = $1 AND "aggregate_id" = $2`, [ aggregateType, aggregateId, ]), + forget: (person) => db.run(`DELETE FROM ${table} WHERE $1 = ANY("persons")`, [person]), }); 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 eb325655..224a93ad 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,8 +43,9 @@ describe("sqlite adapter in memory", () => { inboxLedgerContract({ create: async () => (await openStorage()).inboxLedger }); deadLetterStoreContract({ create: async () => (await openStorage()).deadLetterStore }); schedulerContract({ create: async () => (await openStorage()).scheduler }); - snapshotStoreContract({ create: async () => (await openStorage()).snapshotStore }); + snapshotStoreContract({ create: () => openStorage() }); storageTransactionContract({ create: () => openStorage() }); + personalKeyStoreContract({ create: async () => (await openStorage()).personalKeys }); tableContract({ create: async () => ( @@ -240,6 +242,7 @@ describe("sqlite adapter on a file", () => { "app_events", "app_idempotency_keys", "app_inbox", + "app_personal_keys", "app_rm_order_summary", "app_scheduled_commands", "app_snapshots",