Repository navigation
feat!: let an aggregate's decision carry its expected version - #168
Conversation
An aggregate root is what other aggregates reference and what a repository loads, so `Entity.aggregate` now rejects a field map with no `identity` field: a compile error (an inline rejection literal) plus a declaration-time defect, the redeclaration precedent. `sameIdentityAs` is therefore always available on an aggregate. `Entity` keeps identity optional, since it also models aggregate parts and value-like records. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A repository's `save` needed the decision plus a version threaded by
hand from the load, and chaining two commands then saving the last
decision silently dropped the first one's events.
Each aggregate instance now remembers, beside it rather than on it, the
version it was loaded at and every event decided since. `make` requires
`{ version }`, `replay` uses the stream length, `start` is 0, and `emit`
returns all pending events plus `expectedVersion`. `save(decision)`
needs nothing else; a chain saves as one decision; reusing an
already-saved state is a conflict, never an overwrite.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 26 minutes. View limit detailsLimit details: You’ve used the included review currently available. Review configuration: ⚙️ Run configuration
📒 Files selected for processing (13)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The new aggregate make throws the missing/malformed-version error inside a .map callback without fromThrowable, so per the package's documented Result contract it escapes as an uncaught exception instead of becoming the intended defect.
Review effort: Balanced
Findings: 1
Open (1)
What changed in this PR
This PR makes an aggregate's Decision self-sufficient for persistence. Previously, repositories had to thread the loaded version by hand into save(decision, expected), and chained commands (a.addItem(x).state.addItem(y)) silently dropped earlier events. Now a Decision carries expectedVersion plus every event decided since the aggregate was loaded, and the version/pending-events live in a module-level WeakMap (unsaved) beside each instance rather than on it — so no field name is reserved and nothing leaks into toJSON(). An aggregate's make now requires { version }, replay uses the stream length, and start is version 0. This is a breaking change to the 0.8.0 aggregate API, shipped as a minor changeset.
Changes:
Entity.DecisiongainsexpectedVersion;eventsnow accumulate across chained commands via theunsavedWeakMap.- Aggregate
make(row, { version })requires the version (compile error without it, defect if forced);replay/startderive it; repositories'save(decision)take nothing else. - Example subscription repositories, specs, docs (how-to, entry-points), and
CLAUDE.mdupdated to the new API.
| File | Description |
|---|---|
| packages/entity/src/aggregate.ts | Adds unsaved WeakMap, verify helper, version-aware decide/make, and event accumulation |
| packages/entity/src/types.ts | Decision gains expectedVersion; AggregateStatic.make now requires { version } |
| packages/entity/src/aggregate.test-d.ts | Type tests: make without { version } fails; decision.expectedVersion is a number |
| packages/entity/src/aggregate.spec.ts | Runtime tests for version carrying, chained accumulation, and missing-version defect |
| examples/billing-persistence/src/subscriptions.ts | Repositories drop the separate version arg; load returns the aggregate; removes Loaded |
| examples/billing-persistence/src/subscriptions.spec.ts | Updates scenarios for save(decision) and adds chain/reuse tests |
| docs/reference/entry-points.md | Documents expectedVersion, accumulated events, and make(row, { version }) |
| docs/how-to/model-an-event-driven-aggregate.md | Rewrites persistence section and adds "What a decision remembers" |
| CLAUDE.md | Documents the decision/version model |
| .changeset/aggregate-carries-version.md | Minor (breaking) changeset with migration note |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| function make(this: object, state: unknown, loaded?: { readonly version?: unknown }) { | ||
| return verify.call(this, state).map((instance) => { | ||
| const version = loaded?.version; | ||
| if (typeof version !== "number" || !Number.isInteger(version) || version < 0) { | ||
| // oxlint-disable-next-line unthrown/no-throw | ||
| throw bug("make needs the version the row was loaded at: make(row, { version })"); | ||
| } | ||
| unsaved.set(instance, { version, pending: [] }); | ||
| return instance; | ||
| }); | ||
| } |

Summary
Two DX traps in the shipped aggregate API, fixed together:
save(decision, expected)relied on every use case passing the version from the load to the save. Nothing stopped a wrong or missing one.a.addItem(x).state.addItem(y)gave a second decision holding onlyy. Saving it droppedx.Now:
Entity.DecisiongainsexpectedVersion, and itseventshold every event decided since the aggregate was loaded.make(row, { version })requires the version: a compile error without it, and a defect if forced past the types.replaytakes the stream length;startis0.save(decision)needs nothing else, and use cases no longer thread a version.ConcurrentModification, never an overwrite.WeakMapbeside the instance. They're not a field, not intoJSON(), reserve no name, and the package never interprets the number (row counter or stream position).Examples: both subscription repositories now use
save(decision)andloadreturns the aggregate. The spec gains, for each persistence style, "chained commands save as one decision, losing none of their events" and "deciding again from an already-saved state is a conflict". I skippedLoaded<A>: with the version inside the decision, there's nothing left for it to carry.Docs: the how-to's persistence section is rewritten, plus a new "What a decision remembers" section; entry points (
emit, an aggregate'smake,replay); CLAUDE.md.Breaking for 0.8.0's aggregates: a minor changeset with the migration note.
Entity'smakeis unchanged.Test plan
aggregate.spec.ts(17 tests):expectedVersionfrommake/replay/start; chained accumulation (loaded and brand-new);makewithout a version is a defect; existing tests updated to the accumulated eventsaggregate.test-d.ts:makewithout{ version }doesn't compile;decision.expectedVersionis a numbersubscriptions.spec.ts(12 tests): every scenario on both styles, including chains and reuse after save🤖 Generated with Claude Code