Skip to content
This repository was archived by the owner on Oct 7, 2026. It is now read-only.

feat!: let an aggregate's decision carry its expected version - #168

Merged
btravers merged 2 commits into
mainfrom
feat/aggregate-carries-version
Oct 6, 2026
Merged

btravers merged 2 commits into
mainfrom
feat/aggregate-carries-version

Conversation

@btravers

@btravers btravers commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Builds on #167 (the identity requirement), which touches the same file, and includes its commit until #167 merges. Merge #167 first. Targets main so CI runs.

Summary

Two DX traps in the shipped aggregate API, fixed together:

  1. A version threaded by hand. save(decision, expected) relied on every use case passing the version from the load to the save. Nothing stopped a wrong or missing one.
  2. Chained commands lost events, silently. a.addItem(x).state.addItem(y) gave a second decision holding only y. Saving it dropped x.

Now:

  • Entity.Decision gains expectedVersion, and its events hold every event decided since the aggregate was loaded.
  • An aggregate's make(row, { version }) requires the version: a compile error without it, and a defect if forced past the types. replay takes the stream length; start is 0.
  • A repository's save(decision) needs nothing else, and use cases no longer thread a version.
  • Reusing an already-saved state still carries the old version, so it's a ConcurrentModification, never an overwrite.
  • The version and pending events live in a module WeakMap beside the instance. They're not a field, not in toJSON(), reserve no name, and the package never interprets the number (row counter or stream position).

Examples: both subscription repositories now use save(decision) and load returns 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 skipped Loaded<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's make, replay); CLAUDE.md.

Breaking for 0.8.0's aggregates: a minor changeset with the migration note. Entity's make is unchanged.

Test plan

  • aggregate.spec.ts (17 tests): expectedVersion from make/replay/start; chained accumulation (loaded and brand-new); make without a version is a defect; existing tests updated to the accumulated events
  • aggregate.test-d.ts: make without { version } doesn't compile; decision.expectedVersion is a number
  • subscriptions.spec.ts (12 tests): every scenario on both styles, including chains and reuse after save
  • Full gate: format, lint, typecheck (incl. both emit passes), test, knip, build; TypeDoc warnings 1; all docs anchors resolve
  • CI green

🤖 Generated with Claude Code

Benoit Travers and others added 2 commits October 6, 2026 14:52
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>
Copilot AI balanced review requested due to automatic review settings October 6, 2026 13:01
@coderabbitai

coderabbitai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You'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.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: btravstack/entity/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 5bce27d1-e688-4b77-b2f7-81aba1759f22
📥 Commits

Reviewing files that changed from the base of the PR and between 79162db and 1af25ee.

📒 Files selected for processing (13)
  • .changeset/aggregate-carries-version.md
  • .changeset/aggregate-requires-identity.md
  • CLAUDE.md
  • docs/how-to/model-an-event-driven-aggregate.md
  • docs/reference/declaration.md
  • docs/reference/entry-points.md
  • docs/typedoc.json
  • examples/billing-persistence/src/subscriptions.spec.ts
  • examples/billing-persistence/src/subscriptions.ts
  • packages/entity/src/aggregate.spec.ts
  • packages/entity/src/aggregate.test-d.ts
  • packages/entity/src/aggregate.ts
  • packages/entity/src/types.ts
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@btravers
btravers changed the base branch from feat/aggregate-requires-identity to main October 6, 2026 13:02
@btravers
btravers merged commit 7eb045c into main Oct 6, 2026
2 checks passed
@btravers
btravers deleted the feat/aggregate-carries-version branch October 6, 2026 13:16

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 High severity

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.Decision gains expectedVersion; events now accumulate across chained commands via the unsaved WeakMap.
  • Aggregate make(row, { version }) requires the version (compile error without it, defect if forced); replay/start derive it; repositories' save(decision) take nothing else.
  • Example subscription repositories, specs, docs (how-to, entry-points), and CLAUDE.md updated 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.

Comment on lines +228 to 238
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;
});
}
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants