From 8878818f30a7688af3ecf6e6a810c656d7cdc46c Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 18 Jan 2026 18:35:16 +0800 Subject: [PATCH 1/9] Specs --- .../changes/add-projection-system/design.md | 241 ++++++++++++++++++ .../changes/add-projection-system/proposal.md | 35 +++ .../specs/continuum-persistence/spec.md | 37 +++ .../specs/continuum-projections/spec.md | 212 +++++++++++++++ .../changes/add-projection-system/tasks.md | 119 +++++++++ 5 files changed, 644 insertions(+) create mode 100644 openspec/changes/add-projection-system/design.md create mode 100644 openspec/changes/add-projection-system/proposal.md create mode 100644 openspec/changes/add-projection-system/specs/continuum-persistence/spec.md create mode 100644 openspec/changes/add-projection-system/specs/continuum-projections/spec.md create mode 100644 openspec/changes/add-projection-system/tasks.md diff --git a/openspec/changes/add-projection-system/design.md b/openspec/changes/add-projection-system/design.md new file mode 100644 index 0000000..75ff299 --- /dev/null +++ b/openspec/changes/add-projection-system/design.md @@ -0,0 +1,241 @@ +# Design: Projection and Read-Model System + +## Context + +Continuum needs a projection system that: +- Automatically updates read models when events are appended +- Supports both single-stream and multi-stream projections +- Supports inline (strongly consistent) and async (eventually consistent) execution +- Requires only one-time startup configuration +- Provides predictable consistency guarantees + +Stakeholders: Application developers using Continuum for event sourcing. + +Constraints: +- Must not break existing event sourcing functionality +- Must work with any EventStore implementation +- Must support code generation patterns established in Continuum +- Must keep core lightweight (no heavy dependencies) + +## Goals / Non-Goals + +### Goals +- Declarative projection definitions +- Automatic event-driven updates +- Zero runtime user interaction after configuration +- Support both single-stream and multi-stream projections +- Support both inline and async execution models +- Predictable failure and recovery behavior + +### Non-Goals +- Real-time streaming/push to clients (that's a separate concern) +- Complex event processing (CEP) patterns +- Projection versioning/schema evolution (future work) +- Distributed projection processing (single-node only for v1) + +## Decisions + +### Decision 1: Projection as Pure Event Consumer + +Projections are pure functions that transform events into read model updates. They: +- Do NOT load aggregates +- Do NOT issue commands +- Do NOT have side effects beyond updating the read model +- Receive events and return read model mutations + +**Rationale**: Keeps projections simple, testable, and deterministic. Side effects belong in process managers (future work). + +### Decision 2: Two Projection Types + +#### Single-Stream Projections +- Build read model from one event stream (tied to aggregate identity) +- One projection instance per stream/aggregate +- Deterministic event ordering (per-stream version) +- Use case: per-entity query models, aggregate summaries + +```dart +abstract class SingleStreamProjection { + TId extractId(StoredEvent event); + TReadModel createInitial(TId id); + TReadModel apply(TReadModel current, StoredEvent event); +} +``` + +#### Multi-Stream Projections +- Build read model from events across multiple streams +- Events grouped by a projection-defined key +- Ordering based on global sequence number +- Use case: cross-aggregate views, dashboards, statistics + +```dart +abstract class MultiStreamProjection { + TKey extractKey(StoredEvent event); + TReadModel createInitial(TKey key); + TReadModel apply(TReadModel current, StoredEvent event); +} +``` + +**Rationale**: Clear separation allows optimized implementations and clearer mental model. + +### Decision 3: Execution Lifecycle Selection + +Each projection declares its execution lifecycle: + +#### Inline Projections +- Executed synchronously during `saveChangesAsync()` +- Part of the same logical unit of work +- Failure aborts the event append +- Guarantees: if event exists, projection is updated + +#### Async Projections +- Executed by a background processor +- Event append completes immediately +- Projections resume from last position after restart +- Guarantees: every event is eventually processed + +**Rationale**: Different use cases need different consistency guarantees. Inline for critical reads (e.g., unique constraints), async for non-critical views. + +### Decision 4: Projection Registry + +A central `ProjectionRegistry` maintains: +- All registered projections +- Event type → projection mappings +- Lifecycle (inline/async) per projection +- Projection type (single/multi-stream) + +The registry is consulted on every event append to determine affected projections. + +```dart +final class ProjectionRegistry { + void registerInline(TProjection projection); + void registerAsync(TProjection projection); + + List getInlineProjectionsForEvent(Type eventType); + List getAsyncProjectionsForEvent(Type eventType); +} +``` + +### Decision 5: Read Model Storage Abstraction + +Read models are persisted via a `ReadModelStore` abstraction: + +```dart +abstract interface class ReadModelStore { + Future loadAsync(TKey key); + Future saveAsync(TKey key, TReadModel readModel); + Future deleteAsync(TKey key); +} +``` + +Implementations: +- `InMemoryReadModelStore` for testing/local +- `HiveReadModelStore` for persistent local storage +- Users can implement custom backends + +### Decision 6: Position Tracking for Async Projections + +Async projections track their processing position: + +```dart +abstract interface class ProjectionPositionStore { + Future loadPositionAsync(String projectionName); + Future savePositionAsync(String projectionName, int position); +} +``` + +The position is the `globalSequence` of the last processed event. + +### Decision 7: Background Projection Processor + +A `ProjectionProcessor` runs async projections: + +```dart +abstract interface class ProjectionProcessor { + Future startAsync(); + Future stopAsync(); + Future processAsync(); // Single batch +} +``` + +Implementation polls for new events, applies to relevant projections, and updates positions. + +### Decision 8: Integration Points + +#### EventSourcingStore Changes +```dart +factory EventSourcingStore({ + required EventStore eventStore, + required List aggregates, + ProjectionRegistry? projections, // NEW +}); +``` + +#### Session.saveChangesAsync() Changes +After persisting events, inline projections are executed in the same logical operation. + +### Decision 9: Event Type Filtering + +Projections declare which event types they handle: + +```dart +abstract class Projection { + Set get handledEventTypes; +} +``` + +The registry uses this to efficiently route events. + +## Alternatives Considered + +### Alternative A: Event Bus Pattern +Considered using a publish-subscribe event bus. + +**Rejected because**: +- Adds complexity with no clear benefit +- Harder to guarantee execution order +- Projections would need manual subscription management + +### Alternative B: Reactive Streams +Considered using Stream-based reactive patterns. + +**Rejected because**: +- Adds dependency on async programming model +- Harder to implement inline (synchronous) projections +- Overkill for single-node use case + +### Alternative C: Single Unified Projection Type +Considered having one projection type for both single and multi-stream. + +**Rejected because**: +- Conflates different use cases +- Single-stream can be optimized differently +- Clearer API when separated + +## Risks / Trade-offs + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Inline projections slow down writes | Medium | Document trade-offs; recommend async for non-critical | +| Async projection lag confuses users | Low | Clear documentation; provide lag monitoring | +| Read model storage adds complexity | Medium | Provide simple in-memory default | +| Position tracking failure loses progress | High | Idempotent projections; atomic position updates | + +## Migration Plan + +1. Phase 1: Core abstractions (Projection, Registry, ReadModelStore) +2. Phase 2: Inline projection execution +3. Phase 3: Async projection processor +4. Phase 4: Integration with EventSourcingStore + +Rollback: Feature is additive; disable by not registering projections. + +## Open Questions + +1. **Should projections support batching?** (Process multiple events at once) + - Tentative answer: Yes for async, no for inline initially + +2. **Should we support projection rebuild from scratch?** + - Tentative answer: Yes, via reset position to 0 and reprocess + +3. **Should multi-stream projections support event ordering guarantees?** + - Tentative answer: Ordered by globalSequence; document eventual consistency diff --git a/openspec/changes/add-projection-system/proposal.md b/openspec/changes/add-projection-system/proposal.md new file mode 100644 index 0000000..1904c1a --- /dev/null +++ b/openspec/changes/add-projection-system/proposal.md @@ -0,0 +1,35 @@ +# Change: Add Projection and Read-Model System + +## Why + +Continuum currently supports event sourcing with aggregate persistence, but lacks a way to automatically maintain read models from events. Users must manually subscribe to events, track positions, and update read models—a tedious and error-prone process. + +A projection system enables: +- Automatic read-model updates when events are appended +- Both strongly consistent (inline) and eventually consistent (async) execution models +- Single-stream and multi-stream projections +- Zero runtime user interaction after initial configuration + +## What Changes + +- **ADDED**: Projection abstraction defining how events mutate read models +- **ADDED**: Single-stream projections (one read model per aggregate stream) +- **ADDED**: Multi-stream projections (aggregated read models across streams) +- **ADDED**: Inline projection execution (synchronous, strongly consistent) +- **ADDED**: Async projection execution (background, eventually consistent) +- **ADDED**: Projection registry for automatic event routing +- **ADDED**: Read model storage abstraction +- **ADDED**: Position tracking for async projection recovery +- **ADDED**: Background projection processor +- **MODIFIED**: `EventSourcingStore` to accept projection configuration +- **MODIFIED**: `Session.saveChangesAsync()` to trigger inline projections + +## Impact + +- Affected specs: `continuum-persistence`, new `continuum-projections` capability +- Affected code: + - `packages/continuum/lib/src/persistence/event_sourcing_store.dart` + - `packages/continuum/lib/src/persistence/session_impl.dart` + - New files under `packages/continuum/lib/src/projections/` +- Breaking changes: None (additive feature) +- Migration: None required; existing code continues to work without projections diff --git a/openspec/changes/add-projection-system/specs/continuum-persistence/spec.md b/openspec/changes/add-projection-system/specs/continuum-persistence/spec.md new file mode 100644 index 0000000..ed04b9f --- /dev/null +++ b/openspec/changes/add-projection-system/specs/continuum-persistence/spec.md @@ -0,0 +1,37 @@ +# Capability: continuum-persistence + +## MODIFIED Requirements + +### Requirement: EventSourcingStore configuration +The system SHALL allow `EventSourcingStore` to accept optional projection configuration during construction. + +#### Scenario: Store with projection registry +- **GIVEN** an EventSourcingStore constructor call +- **WHEN** a `ProjectionRegistry` is provided +- **THEN** sessions from this store execute registered inline projections on save + +#### Scenario: Store without projection registry +- **GIVEN** an EventSourcingStore constructor call +- **WHEN** no `ProjectionRegistry` is provided +- **THEN** the store operates as before with no projection processing + +### Requirement: Session saveChangesAsync with projections +The system SHALL execute inline projections as part of `Session.saveChangesAsync()` when projections are configured. + +#### Scenario: saveChangesAsync triggers inline projections +- **GIVEN** a session from a store with inline projections configured +- **WHEN** `saveChangesAsync()` is called with pending events +- **THEN** events are persisted +- **AND** all matching inline projections are executed before the method returns + +#### Scenario: Inline projection failure rolls back +- **GIVEN** an inline projection that fails during execution +- **WHEN** `saveChangesAsync()` is called +- **THEN** no events are persisted +- **AND** no read models are updated +- **AND** an exception is thrown + +#### Scenario: saveChangesAsync without projections unchanged +- **GIVEN** a session from a store without projection configuration +- **WHEN** `saveChangesAsync()` is called +- **THEN** only event persistence occurs (no projection overhead) diff --git a/openspec/changes/add-projection-system/specs/continuum-projections/spec.md b/openspec/changes/add-projection-system/specs/continuum-projections/spec.md new file mode 100644 index 0000000..6c560a1 --- /dev/null +++ b/openspec/changes/add-projection-system/specs/continuum-projections/spec.md @@ -0,0 +1,212 @@ +# Capability: continuum-projections + +## ADDED Requirements + +### Requirement: Projection abstraction +The system SHALL provide a `Projection` abstraction that defines how events mutate a read model. + +#### Scenario: Projection declares handled event types +- **GIVEN** a projection implementation +- **WHEN** the projection is queried for handled event types +- **THEN** it returns the set of event types it reacts to + +#### Scenario: Projection applies event to read model +- **GIVEN** a projection and an event it handles +- **WHEN** the event is applied +- **THEN** the projection returns an updated read model + +### Requirement: Single-stream projection +The system SHALL provide a `SingleStreamProjection` abstraction for building read models from one event stream. + +#### Scenario: Single-stream projection extracts stream identity +- **GIVEN** a single-stream projection and a stored event +- **WHEN** the projection extracts the ID +- **THEN** it returns the stream identity for read model lookup + +#### Scenario: Single-stream projection creates initial read model +- **GIVEN** a single-stream projection and a stream ID with no existing read model +- **WHEN** the first event is processed +- **THEN** the projection creates an initial read model for that stream + +#### Scenario: Single-stream projection applies events in stream order +- **GIVEN** a stream with events at versions 0, 1, 2 +- **WHEN** the projection processes the stream +- **THEN** events are applied in version order (0, 1, 2) + +### Requirement: Multi-stream projection +The system SHALL provide a `MultiStreamProjection` abstraction for building read models from events across multiple streams. + +#### Scenario: Multi-stream projection extracts grouping key +- **GIVEN** a multi-stream projection and a stored event +- **WHEN** the projection extracts the key +- **THEN** it returns the grouping key for read model lookup + +#### Scenario: Multi-stream projection aggregates across streams +- **GIVEN** events from streams A and B that map to the same key K +- **WHEN** the projection processes these events +- **THEN** both events are applied to the same read model for key K + +#### Scenario: Multi-stream projection uses global ordering +- **GIVEN** events with global sequences 10, 20, 30 from different streams +- **WHEN** the projection processes these events +- **THEN** events are applied in global sequence order (10, 20, 30) + +### Requirement: Projection registry +The system SHALL provide a `ProjectionRegistry` that tracks all registered projections and their configuration. + +#### Scenario: Register inline projection +- **GIVEN** a projection registry +- **WHEN** a projection is registered as inline +- **THEN** it is stored with inline lifecycle configuration + +#### Scenario: Register async projection +- **GIVEN** a projection registry +- **WHEN** a projection is registered as async +- **THEN** it is stored with async lifecycle configuration + +#### Scenario: Query projections for event type +- **GIVEN** a registry with projections A (handles E1, E2) and B (handles E2, E3) +- **WHEN** queried for projections handling event type E2 +- **THEN** both A and B are returned + +### Requirement: Inline projection execution +The system SHALL execute inline projections synchronously during event persistence. + +#### Scenario: Inline projection updates on saveChangesAsync +- **GIVEN** an inline projection P registered for event type E +- **AND** a session with pending event E +- **WHEN** `saveChangesAsync()` is called +- **THEN** projection P is applied to event E before the method returns + +#### Scenario: Inline projection failure aborts event append +- **GIVEN** an inline projection that throws an exception +- **WHEN** `saveChangesAsync()` is called +- **THEN** no events are persisted +- **AND** the exception propagates to the caller + +#### Scenario: Inline projection guarantees strong consistency +- **GIVEN** an inline projection P for event type E +- **WHEN** `saveChangesAsync()` completes successfully +- **THEN** the read model is immediately consistent with the persisted events + +### Requirement: Async projection execution +The system SHALL execute async projections via a background processor after event persistence. + +#### Scenario: Async projection does not block saveChangesAsync +- **GIVEN** an async projection P registered for event type E +- **AND** a session with pending event E +- **WHEN** `saveChangesAsync()` is called +- **THEN** the method returns immediately after events are persisted +- **AND** projection P is scheduled for background processing + +#### Scenario: Async projection failure does not affect event append +- **GIVEN** an async projection that throws an exception +- **WHEN** `saveChangesAsync()` is called +- **THEN** events are persisted successfully +- **AND** the projection failure is logged for retry + +#### Scenario: Async projection eventual consistency +- **GIVEN** an async projection P for event type E +- **WHEN** `saveChangesAsync()` completes +- **THEN** the read model may not yet be updated +- **AND** the read model will eventually be updated by the background processor + +### Requirement: Read model storage +The system SHALL provide a `ReadModelStore` abstraction for persisting read models. + +#### Scenario: Load existing read model +- **GIVEN** a read model store with a stored model for key K +- **WHEN** `loadAsync(K)` is called +- **THEN** the stored read model is returned + +#### Scenario: Load missing read model +- **GIVEN** a read model store with no model for key K +- **WHEN** `loadAsync(K)` is called +- **THEN** null is returned + +#### Scenario: Save read model +- **GIVEN** a read model store and a read model M for key K +- **WHEN** `saveAsync(K, M)` is called +- **THEN** subsequent `loadAsync(K)` returns M + +#### Scenario: Delete read model +- **GIVEN** a read model store with a model for key K +- **WHEN** `deleteAsync(K)` is called +- **THEN** subsequent `loadAsync(K)` returns null + +### Requirement: Projection position tracking +The system SHALL track the last processed event position for each async projection. + +#### Scenario: Load projection position +- **GIVEN** a position store with position 42 for projection P +- **WHEN** `loadPositionAsync("P")` is called +- **THEN** 42 is returned + +#### Scenario: Save projection position +- **GIVEN** a position store +- **WHEN** `savePositionAsync("P", 100)` is called +- **THEN** subsequent `loadPositionAsync("P")` returns 100 + +#### Scenario: Position starts at null for new projection +- **GIVEN** a position store with no entry for projection P +- **WHEN** `loadPositionAsync("P")` is called +- **THEN** null is returned (indicating process from beginning) + +### Requirement: Background projection processor +The system SHALL provide a `ProjectionProcessor` for running async projections. + +#### Scenario: Processor starts and runs continuously +- **GIVEN** a projection processor with registered async projections +- **WHEN** `startAsync()` is called +- **THEN** the processor begins polling for new events + +#### Scenario: Processor stops gracefully +- **GIVEN** a running projection processor +- **WHEN** `stopAsync()` is called +- **THEN** the processor completes current work and stops + +#### Scenario: Processor resumes from last position +- **GIVEN** projection P with last position 50 +- **WHEN** the processor restarts +- **THEN** it processes events starting from global sequence 51 + +#### Scenario: Processor updates position after successful processing +- **GIVEN** projection P processing event with global sequence 100 +- **WHEN** the event is successfully applied +- **THEN** P's position is updated to 100 + +### Requirement: Automatic projection updates +The system SHALL automatically update all applicable projections when events are appended. + +#### Scenario: Zero runtime user interaction +- **GIVEN** projections registered during startup +- **WHEN** events are appended via any session +- **THEN** all applicable projections are updated automatically +- **AND** no user code invokes projections manually + +#### Scenario: New events trigger all matching projections +- **GIVEN** projections A (inline, handles E1) and B (async, handles E1, E2) +- **WHEN** event E1 is appended +- **THEN** projection A is applied immediately +- **AND** projection B is scheduled for async processing + +### Requirement: Projection idempotency support +The system SHALL support idempotent projection processing to handle reprocessing safely. + +#### Scenario: Reprocessing same event is safe +- **GIVEN** a projection that has already processed event E at position P +- **WHEN** the same event E is reprocessed (e.g., after crash recovery) +- **THEN** the read model remains correct (no duplicate effects) + +### Requirement: EventSourcingStore projection integration +The system SHALL integrate projections into `EventSourcingStore` configuration. + +#### Scenario: Configure store with projections +- **GIVEN** an EventSourcingStore factory call with projection registry +- **WHEN** the store is created +- **THEN** sessions from this store automatically execute inline projections + +#### Scenario: Store without projections works unchanged +- **GIVEN** an EventSourcingStore factory call without projection registry +- **WHEN** sessions append events +- **THEN** no projection processing occurs (backward compatible) diff --git a/openspec/changes/add-projection-system/tasks.md b/openspec/changes/add-projection-system/tasks.md new file mode 100644 index 0000000..c95813a --- /dev/null +++ b/openspec/changes/add-projection-system/tasks.md @@ -0,0 +1,119 @@ +# Tasks: Add Projection and Read-Model System + +## 1. Core Projection Abstractions + +- [ ] 1.1 Create `Projection` base class with `handledEventTypes` property +- [ ] 1.2 Create `SingleStreamProjection` abstract class + - `extractId(StoredEvent) -> TId` + - `createInitial(TId) -> TReadModel` + - `apply(TReadModel, StoredEvent) -> TReadModel` +- [ ] 1.3 Create `MultiStreamProjection` abstract class + - `extractKey(StoredEvent) -> TKey` + - `createInitial(TKey) -> TReadModel` + - `apply(TReadModel, StoredEvent) -> TReadModel` +- [ ] 1.4 Add unit tests for projection type definitions + +## 2. Projection Registry + +- [ ] 2.1 Create `ProjectionLifecycle` enum (inline, async) +- [ ] 2.2 Create `ProjectionRegistration` class holding projection + lifecycle + metadata +- [ ] 2.3 Create `ProjectionRegistry` class + - `registerInline(TProjection projection)` + - `registerAsync(TProjection projection)` + - `getInlineProjectionsForEvent(Type eventType) -> List` + - `getAsyncProjectionsForEvent(Type eventType) -> List` +- [ ] 2.4 Add unit tests for registry registration and lookup + +## 3. Read Model Storage + +- [ ] 3.1 Create `ReadModelStore` abstract interface + - `loadAsync(TKey) -> TReadModel?` + - `saveAsync(TKey, TReadModel) -> void` + - `deleteAsync(TKey) -> void` +- [ ] 3.2 Create `InMemoryReadModelStore` implementation for testing +- [ ] 3.3 Add unit tests for read model store contract + +## 4. Position Tracking + +- [ ] 4.1 Create `ProjectionPositionStore` abstract interface + - `loadPositionAsync(String projectionName) -> int?` + - `savePositionAsync(String projectionName, int position) -> void` +- [ ] 4.2 Create `InMemoryProjectionPositionStore` implementation +- [ ] 4.3 Add unit tests for position store contract + +## 5. Inline Projection Execution + +- [ ] 5.1 Create `InlineProjectionExecutor` class + - Accepts registry, read model stores + - `executeAsync(List events)` applies matching inline projections +- [ ] 5.2 Handle projection failures (abort, propagate exception) +- [ ] 5.3 Add unit tests for inline execution scenarios + +## 6. Async Projection Execution + +- [ ] 6.1 Create `AsyncProjectionExecutor` class + - Accepts registry, read model stores, position store + - `processEventsAsync(List events)` applies matching async projections +- [ ] 6.2 Implement position tracking (load, update after success) +- [ ] 6.3 Handle projection failures (log, retry logic) +- [ ] 6.4 Add unit tests for async execution scenarios + +## 7. Background Projection Processor + +- [ ] 7.1 Create `ProjectionProcessor` abstract interface + - `startAsync()` / `stopAsync()` / `processAsync()` +- [ ] 7.2 Create `PollingProjectionProcessor` implementation + - Polls event store for new events (after last global sequence) + - Processes batches through async executor +- [ ] 7.3 Add unit tests for processor lifecycle + +## 8. Event Store Integration + +- [ ] 8.1 Add method to `EventStore` interface: `loadEventsFromPositionAsync(int fromGlobalSequence, int limit) -> List` +- [ ] 8.2 Implement in `InMemoryEventStore` +- [ ] 8.3 Implement in `HiveEventStore` +- [ ] 8.4 Add unit tests for new event store method + +## 9. Session Integration + +- [ ] 9.1 Modify `SessionImpl` to accept optional `InlineProjectionExecutor` +- [ ] 9.2 Modify `saveChangesAsync()` to: + - Persist events + - Execute inline projections with persisted events + - Roll back if inline projection fails (or make atomic) +- [ ] 9.3 Add unit tests for session with inline projections + +## 10. EventSourcingStore Integration + +- [ ] 10.1 Add optional `ProjectionRegistry` parameter to `EventSourcingStore` factory +- [ ] 10.2 Create `InlineProjectionExecutor` from registry when configured +- [ ] 10.3 Pass executor to sessions via `openSession()` +- [ ] 10.4 Add integration tests for store with projections + +## 11. Public API Exports + +- [ ] 11.1 Add projection exports to `lib/continuum.dart`: + - `src/projections/projection.dart` + - `src/projections/single_stream_projection.dart` + - `src/projections/multi_stream_projection.dart` + - `src/projections/projection_registry.dart` + - `src/projections/read_model_store.dart` + - `src/projections/projection_position_store.dart` + - `src/projections/projection_processor.dart` + +## 12. Documentation + +- [ ] 12.1 Add CHANGELOG entry for projection system +- [ ] 12.2 Update README with projection usage examples +- [ ] 12.3 Add example code demonstrating: + - Single-stream projection setup + - Multi-stream projection setup + - Inline vs async configuration + - Background processor startup + +## 13. Final Validation + +- [ ] 13.1 Run full test suite +- [ ] 13.2 Run `dart analyze` with zero warnings +- [ ] 13.3 Verify backward compatibility (existing tests pass unchanged) +- [ ] 13.4 Manual integration test with example app From a23db72934f6a4d3d06f392fa49e50dfdcb4151c Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 18 Jan 2026 21:48:31 +0800 Subject: [PATCH 2/9] Basic implementation --- CHANGELOG.md | 16 + .../changes/add-projection-system/tasks.md | 84 ++--- packages/continuum/README.md | 202 ++++++++++++ packages/continuum/lib/continuum.dart | 15 + .../src/persistence/event_sourcing_store.dart | 29 +- .../persistence/projection_event_store.dart | 32 ++ .../lib/src/persistence/session_impl.dart | 56 +++- .../async_projection_executor.dart | 139 +++++++++ .../inline_projection_executor.dart | 100 ++++++ .../projections/multi_stream_projection.dart | 61 ++++ .../lib/src/projections/projection.dart | 43 +++ .../src/projections/projection_lifecycle.dart | 23 ++ .../projection_position_store.dart | 54 ++++ .../src/projections/projection_processor.dart | 188 +++++++++++ .../projections/projection_registration.dart | 38 +++ .../src/projections/projection_registry.dart | 148 +++++++++ .../lib/src/projections/read_model_store.dart | 59 ++++ .../projections/single_stream_projection.dart | 54 ++++ .../async_projection_executor_test.dart | 293 ++++++++++++++++++ .../inline_projection_executor_test.dart | 215 +++++++++++++ .../projection_position_store_test.dart | 98 ++++++ .../projection_processor_test.dart | 224 +++++++++++++ .../projections/projection_registry_test.dart | 211 +++++++++++++ .../test/projections/projection_test.dart | 218 +++++++++++++ .../projections/read_model_store_test.dart | 132 ++++++++ .../lib/src/hive_event_store.dart | 40 ++- .../lib/src/in_memory_event_store.dart | 36 ++- .../test/in_memory_event_store_test.dart | 82 +++++ 28 files changed, 2828 insertions(+), 62 deletions(-) create mode 100644 packages/continuum/lib/src/persistence/projection_event_store.dart create mode 100644 packages/continuum/lib/src/projections/async_projection_executor.dart create mode 100644 packages/continuum/lib/src/projections/inline_projection_executor.dart create mode 100644 packages/continuum/lib/src/projections/multi_stream_projection.dart create mode 100644 packages/continuum/lib/src/projections/projection.dart create mode 100644 packages/continuum/lib/src/projections/projection_lifecycle.dart create mode 100644 packages/continuum/lib/src/projections/projection_position_store.dart create mode 100644 packages/continuum/lib/src/projections/projection_processor.dart create mode 100644 packages/continuum/lib/src/projections/projection_registration.dart create mode 100644 packages/continuum/lib/src/projections/projection_registry.dart create mode 100644 packages/continuum/lib/src/projections/read_model_store.dart create mode 100644 packages/continuum/lib/src/projections/single_stream_projection.dart create mode 100644 packages/continuum/test/projections/async_projection_executor_test.dart create mode 100644 packages/continuum/test/projections/inline_projection_executor_test.dart create mode 100644 packages/continuum/test/projections/projection_position_store_test.dart create mode 100644 packages/continuum/test/projections/projection_processor_test.dart create mode 100644 packages/continuum/test/projections/projection_registry_test.dart create mode 100644 packages/continuum/test/projections/projection_test.dart create mode 100644 packages/continuum/test/projections/read_model_store_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cc2e4a..7bff2b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Projection System**: A comprehensive read-model projection framework with automatic event-driven updates. + - `Projection`: Base class for event-driven read model maintenance. + - `SingleStreamProjection`: Projections that follow a single aggregate stream. + - `MultiStreamProjection`: Projections that aggregate across multiple streams. + - `ProjectionRegistry`: Central registry for projection registration and lookup. + - `ReadModelStore`: Storage abstraction for read models with `InMemoryReadModelStore` implementation. + - `ProjectionPositionStore`: Tracks projection progress with `InMemoryProjectionPositionStore` implementation. + - `InlineProjectionExecutor`: Synchronous execution for strongly-consistent projections. + - `AsyncProjectionExecutor`: Asynchronous execution for eventually-consistent projections. + - `PollingProjectionProcessor`: Background processor for async projections. + - `ProjectionEventStore`: Interface for event stores that support projection queries. +- `EventSourcingStore` now accepts an optional `projections` parameter to enable automatic inline projection execution. +- `InMemoryEventStore` and `HiveEventStore` now implement `ProjectionEventStore` for projection support. + ### Fixed - `continuum_missing_apply_handlers` no longer requires `apply(...)` handlers for creation events marked with `@AggregateEvent(creation: true)`. diff --git a/openspec/changes/add-projection-system/tasks.md b/openspec/changes/add-projection-system/tasks.md index c95813a..f575fe5 100644 --- a/openspec/changes/add-projection-system/tasks.md +++ b/openspec/changes/add-projection-system/tasks.md @@ -2,97 +2,97 @@ ## 1. Core Projection Abstractions -- [ ] 1.1 Create `Projection` base class with `handledEventTypes` property -- [ ] 1.2 Create `SingleStreamProjection` abstract class +- [x] 1.1 Create `Projection` base class with `handledEventTypes` property +- [x] 1.2 Create `SingleStreamProjection` abstract class - `extractId(StoredEvent) -> TId` - `createInitial(TId) -> TReadModel` - `apply(TReadModel, StoredEvent) -> TReadModel` -- [ ] 1.3 Create `MultiStreamProjection` abstract class +- [x] 1.3 Create `MultiStreamProjection` abstract class - `extractKey(StoredEvent) -> TKey` - `createInitial(TKey) -> TReadModel` - `apply(TReadModel, StoredEvent) -> TReadModel` -- [ ] 1.4 Add unit tests for projection type definitions +- [x] 1.4 Add unit tests for projection type definitions ## 2. Projection Registry -- [ ] 2.1 Create `ProjectionLifecycle` enum (inline, async) -- [ ] 2.2 Create `ProjectionRegistration` class holding projection + lifecycle + metadata -- [ ] 2.3 Create `ProjectionRegistry` class +- [x] 2.1 Create `ProjectionLifecycle` enum (inline, async) +- [x] 2.2 Create `ProjectionRegistration` class holding projection + lifecycle + metadata +- [x] 2.3 Create `ProjectionRegistry` class - `registerInline(TProjection projection)` - `registerAsync(TProjection projection)` - `getInlineProjectionsForEvent(Type eventType) -> List` - `getAsyncProjectionsForEvent(Type eventType) -> List` -- [ ] 2.4 Add unit tests for registry registration and lookup +- [x] 2.4 Add unit tests for registry registration and lookup ## 3. Read Model Storage -- [ ] 3.1 Create `ReadModelStore` abstract interface +- [x] 3.1 Create `ReadModelStore` abstract interface - `loadAsync(TKey) -> TReadModel?` - `saveAsync(TKey, TReadModel) -> void` - `deleteAsync(TKey) -> void` -- [ ] 3.2 Create `InMemoryReadModelStore` implementation for testing -- [ ] 3.3 Add unit tests for read model store contract +- [x] 3.2 Create `InMemoryReadModelStore` implementation for testing +- [x] 3.3 Add unit tests for read model store contract ## 4. Position Tracking -- [ ] 4.1 Create `ProjectionPositionStore` abstract interface +- [x] 4.1 Create `ProjectionPositionStore` abstract interface - `loadPositionAsync(String projectionName) -> int?` - `savePositionAsync(String projectionName, int position) -> void` -- [ ] 4.2 Create `InMemoryProjectionPositionStore` implementation -- [ ] 4.3 Add unit tests for position store contract +- [x] 4.2 Create `InMemoryProjectionPositionStore` implementation +- [x] 4.3 Add unit tests for position store contract ## 5. Inline Projection Execution -- [ ] 5.1 Create `InlineProjectionExecutor` class +- [x] 5.1 Create `InlineProjectionExecutor` class - Accepts registry, read model stores - `executeAsync(List events)` applies matching inline projections -- [ ] 5.2 Handle projection failures (abort, propagate exception) -- [ ] 5.3 Add unit tests for inline execution scenarios +- [x] 5.2 Handle projection failures (abort, propagate exception) +- [x] 5.3 Add unit tests for inline execution scenarios ## 6. Async Projection Execution -- [ ] 6.1 Create `AsyncProjectionExecutor` class +- [x] 6.1 Create `AsyncProjectionExecutor` class - Accepts registry, read model stores, position store - `processEventsAsync(List events)` applies matching async projections -- [ ] 6.2 Implement position tracking (load, update after success) -- [ ] 6.3 Handle projection failures (log, retry logic) -- [ ] 6.4 Add unit tests for async execution scenarios +- [x] 6.2 Implement position tracking (load, update after success) +- [x] 6.3 Handle projection failures (log, retry logic) +- [x] 6.4 Add unit tests for async execution scenarios ## 7. Background Projection Processor -- [ ] 7.1 Create `ProjectionProcessor` abstract interface +- [x] 7.1 Create `ProjectionProcessor` abstract interface - `startAsync()` / `stopAsync()` / `processAsync()` -- [ ] 7.2 Create `PollingProjectionProcessor` implementation +- [x] 7.2 Create `PollingProjectionProcessor` implementation - Polls event store for new events (after last global sequence) - Processes batches through async executor -- [ ] 7.3 Add unit tests for processor lifecycle +- [x] 7.3 Add unit tests for processor lifecycle ## 8. Event Store Integration -- [ ] 8.1 Add method to `EventStore` interface: `loadEventsFromPositionAsync(int fromGlobalSequence, int limit) -> List` -- [ ] 8.2 Implement in `InMemoryEventStore` -- [ ] 8.3 Implement in `HiveEventStore` -- [ ] 8.4 Add unit tests for new event store method +- [x] 8.1 Add method to `EventStore` interface: `loadEventsFromPositionAsync(int fromGlobalSequence, int limit) -> List` +- [x] 8.2 Implement in `InMemoryEventStore` +- [x] 8.3 Implement in `HiveEventStore` +- [x] 8.4 Add unit tests for new event store method ## 9. Session Integration -- [ ] 9.1 Modify `SessionImpl` to accept optional `InlineProjectionExecutor` -- [ ] 9.2 Modify `saveChangesAsync()` to: +- [x] 9.1 Modify `SessionImpl` to accept optional `InlineProjectionExecutor` +- [x] 9.2 Modify `saveChangesAsync()` to: - Persist events - Execute inline projections with persisted events - Roll back if inline projection fails (or make atomic) -- [ ] 9.3 Add unit tests for session with inline projections +- [x] 9.3 Add unit tests for session with inline projections ## 10. EventSourcingStore Integration -- [ ] 10.1 Add optional `ProjectionRegistry` parameter to `EventSourcingStore` factory -- [ ] 10.2 Create `InlineProjectionExecutor` from registry when configured -- [ ] 10.3 Pass executor to sessions via `openSession()` -- [ ] 10.4 Add integration tests for store with projections +- [x] 10.1 Add optional `ProjectionRegistry` parameter to `EventSourcingStore` factory +- [x] 10.2 Create `InlineProjectionExecutor` from registry when configured +- [x] 10.3 Pass executor to sessions via `openSession()` +- [x] 10.4 Add integration tests for store with projections ## 11. Public API Exports -- [ ] 11.1 Add projection exports to `lib/continuum.dart`: +- [x] 11.1 Add projection exports to `lib/continuum.dart`: - `src/projections/projection.dart` - `src/projections/single_stream_projection.dart` - `src/projections/multi_stream_projection.dart` @@ -103,9 +103,9 @@ ## 12. Documentation -- [ ] 12.1 Add CHANGELOG entry for projection system -- [ ] 12.2 Update README with projection usage examples -- [ ] 12.3 Add example code demonstrating: +- [x] 12.1 Add CHANGELOG entry for projection system +- [x] 12.2 Update README with projection usage examples +- [x] 12.3 Add example code demonstrating: - Single-stream projection setup - Multi-stream projection setup - Inline vs async configuration @@ -113,7 +113,7 @@ ## 13. Final Validation -- [ ] 13.1 Run full test suite -- [ ] 13.2 Run `dart analyze` with zero warnings -- [ ] 13.3 Verify backward compatibility (existing tests pass unchanged) +- [x] 13.1 Run full test suite +- [x] 13.2 Run `dart analyze` with zero warnings +- [x] 13.3 Verify backward compatibility (existing tests pass unchanged) - [ ] 13.4 Manual integration test with example app diff --git a/packages/continuum/README.md b/packages/continuum/README.md index ced6752..6402fd6 100644 --- a/packages/continuum/README.md +++ b/packages/continuum/README.md @@ -494,6 +494,208 @@ The `of` links the event to its aggregate. The `type` string identifies the even - [Memory store](../continuum_store_memory/example/lib/main.dart) - Event sourcing persistence - [Hive store](../continuum_store_hive/example/lib/main.dart) - Local database persistence +## Projections (Read Models) + +Projections maintain **read models** that are automatically updated when events occur. This enables CQRS (Command Query Responsibility Segregation) patterns where you have optimized read views separate from your event-sourced aggregates. + +### Why Use Projections? + +1. **Query Efficiency**: Read models are optimized for specific queries without reconstructing aggregates. +2. **Denormalization**: Combine data from multiple aggregates into a single view. +3. **Performance**: Avoid replaying events for every read operation. + +### Single-Stream Projections + +Track state for a single aggregate stream (e.g., user profile): + +```dart +/// Read model for a user's profile information. +class UserProfile { + final String name; + final String email; + final DateTime lastUpdated; + + const UserProfile({ + required this.name, + required this.email, + required this.lastUpdated, + }); + + UserProfile copyWith({String? name, String? email, DateTime? lastUpdated}) { + return UserProfile( + name: name ?? this.name, + email: email ?? this.email, + lastUpdated: lastUpdated ?? this.lastUpdated, + ); + } +} + +/// Projection that maintains UserProfile read models. +class UserProfileProjection extends SingleStreamProjection { + @override + String get projectionName => 'user-profile'; + + @override + Set get handledEventTypes => {UserRegistered, EmailChanged, NameChanged}; + + @override + UserProfile createInitial() => const UserProfile( + name: '', + email: '', + lastUpdated: DateTime(1970), + ); + + @override + UserProfile apply(UserProfile current, ContinuumEvent event) { + return switch (event) { + UserRegistered e => UserProfile( + name: e.name, + email: e.email, + lastUpdated: e.occurredOn, + ), + EmailChanged e => current.copyWith( + email: e.newEmail, + lastUpdated: e.occurredOn, + ), + NameChanged e => current.copyWith( + name: e.newName, + lastUpdated: e.occurredOn, + ), + _ => current, + }; + } +} +``` + +### Multi-Stream Projections + +Aggregate data across multiple streams (e.g., statistics, dashboards): + +```dart +/// Read model for system-wide user statistics. +class UserStatistics { + final int totalUsers; + final int activeUsers; + + const UserStatistics({ + required this.totalUsers, + required this.activeUsers, + }); +} + +/// Projection that tracks statistics across all user streams. +class UserStatisticsProjection extends MultiStreamProjection { + @override + String get projectionName => 'user-statistics'; + + @override + Set get handledEventTypes => {UserRegistered, UserDeactivated, UserReactivated}; + + @override + String extractKey(ContinuumEvent event, StreamId streamId) => 'global'; + + @override + UserStatistics createInitial() => const UserStatistics(totalUsers: 0, activeUsers: 0); + + @override + UserStatistics apply(UserStatistics current, ContinuumEvent event) { + return switch (event) { + UserRegistered() => UserStatistics( + totalUsers: current.totalUsers + 1, + activeUsers: current.activeUsers + 1, + ), + UserDeactivated() => UserStatistics( + totalUsers: current.totalUsers, + activeUsers: current.activeUsers - 1, + ), + UserReactivated() => UserStatistics( + totalUsers: current.totalUsers, + activeUsers: current.activeUsers + 1, + ), + _ => current, + }; + } +} +``` + +### Projection Lifecycle + +Projections support two execution modes: + +#### Inline (Strongly Consistent) + +Projections execute during `saveChangesAsync()`. The read model is always up-to-date with the event store. + +```dart +final registry = ProjectionRegistry(); +final userProfileStore = InMemoryReadModelStore(); + +registry.registerInline( + UserProfileProjection(), + userProfileStore, +); + +// Pass to EventSourcingStore +final store = EventSourcingStore( + eventStore: InMemoryEventStore(), + aggregates: $aggregateList, + projections: registry, // Inline projections execute automatically +); +``` + +#### Async (Eventually Consistent) + +Projections execute in the background after events are appended. Better for high-throughput scenarios. + +```dart +final registry = ProjectionRegistry(); +final statisticsStore = InMemoryReadModelStore(); + +registry.registerAsync( + UserStatisticsProjection(), + statisticsStore, +); + +// Create background processor +final processor = PollingProjectionProcessor( + eventStore: eventStore, // Must implement ProjectionEventStore + executor: AsyncProjectionExecutor(registry: registry), + positionStore: InMemoryProjectionPositionStore(), + pollInterval: Duration(seconds: 1), +); + +// Start processing +await processor.startAsync(); + +// ... later +await processor.stopAsync(); +``` + +### Read Model Storage + +Projections store their state in `ReadModelStore` implementations: + +```dart +// In-memory storage (for testing or volatile read models) +final store = InMemoryReadModelStore(); + +// Load a read model +final profile = await store.loadAsync(userId); + +// Custom storage - implement the interface +class PostgresReadModelStore implements ReadModelStore { + @override + Future loadAsync(K key) async { /* ... */ } + + @override + Future saveAsync(K key, T readModel) async { /* ... */ } + + @override + Future deleteAsync(K key) async { /* ... */ } +} +``` + ## Contributing See the [repository](https://github.com/zooper-lib/continuum) for contribution guidelines. + diff --git a/packages/continuum/lib/continuum.dart b/packages/continuum/lib/continuum.dart index 3831fef..ac34e0c 100644 --- a/packages/continuum/lib/continuum.dart +++ b/packages/continuum/lib/continuum.dart @@ -31,5 +31,20 @@ export 'src/persistence/event_store.dart'; export 'src/persistence/expected_version.dart'; export 'src/persistence/generated_aggregate.dart'; export 'src/persistence/json_event_serializer.dart'; +export 'src/persistence/projection_event_store.dart'; export 'src/persistence/session.dart'; export 'src/persistence/stored_event.dart'; + +// Projection system +export 'src/projections/async_projection_executor.dart'; +export 'src/projections/inline_projection_executor.dart'; +export 'src/projections/multi_stream_projection.dart'; +export 'src/projections/projection.dart'; +export 'src/projections/projection_lifecycle.dart'; +export 'src/projections/projection_position_store.dart'; +export 'src/projections/projection_processor.dart'; +export 'src/projections/projection_registration.dart'; +export 'src/projections/projection_registry.dart'; +export 'src/projections/read_model_store.dart'; +export 'src/projections/single_stream_projection.dart'; + diff --git a/packages/continuum/lib/src/persistence/event_sourcing_store.dart b/packages/continuum/lib/src/persistence/event_sourcing_store.dart index cbc02ce..1f60033 100644 --- a/packages/continuum/lib/src/persistence/event_sourcing_store.dart +++ b/packages/continuum/lib/src/persistence/event_sourcing_store.dart @@ -1,3 +1,5 @@ +import '../projections/inline_projection_executor.dart'; +import '../projections/projection_registry.dart'; import 'event_serializer.dart'; import 'event_serializer_registry.dart'; import 'event_store.dart'; @@ -12,6 +14,9 @@ import 'session_impl.dart'; /// to provide a complete event sourcing runtime. Sessions are created /// from this store to perform aggregate operations. /// +/// Optionally accepts a [ProjectionRegistry] to enable automatic +/// inline projection execution during event persistence. +/// /// ```dart /// final store = EventSourcingStore( /// eventStore: InMemoryEventStore(), @@ -34,21 +39,29 @@ final class EventSourcingStore { /// Event applier registry for applying events to aggregates. final EventApplierRegistry _eventAppliers; + /// Optional executor for inline projections. + final InlineProjectionExecutor? _inlineProjectionExecutor; + /// Creates an event sourcing store from generated aggregate bundles. /// /// This is the recommended constructor. Pass all your generated /// aggregate bundles (e.g., `$User`, `$Account`) and the store /// will automatically merge their registries. /// + /// Optionally provide a [projections] registry to enable automatic + /// inline projection execution when events are saved. + /// /// ```dart /// final store = EventSourcingStore( /// eventStore: InMemoryEventStore(), /// aggregates: [$User, $Account], + /// projections: projectionRegistry, // Optional /// ); /// ``` factory EventSourcingStore({ required EventStore eventStore, required List aggregates, + ProjectionRegistry? projections, }) { // Merge all registries from the provided aggregates var serializerRegistry = const EventSerializerRegistry.empty(); @@ -61,11 +74,18 @@ final class EventSourcingStore { eventAppliers = eventAppliers.merge(aggregate.eventAppliers); } + // Create inline projection executor if projections are configured. + InlineProjectionExecutor? inlineProjectionExecutor; + if (projections != null && projections.hasInlineProjections) { + inlineProjectionExecutor = InlineProjectionExecutor(registry: projections); + } + return EventSourcingStore._( eventStore: eventStore, serializer: JsonEventSerializer(registry: serializerRegistry), aggregateFactories: aggregateFactories, eventAppliers: eventAppliers, + inlineProjectionExecutor: inlineProjectionExecutor, ); } @@ -78,21 +98,28 @@ final class EventSourcingStore { required EventSerializer serializer, required AggregateFactoryRegistry aggregateFactories, required EventApplierRegistry eventAppliers, + InlineProjectionExecutor? inlineProjectionExecutor, }) : _eventStore = eventStore, _serializer = serializer, _aggregateFactories = aggregateFactories, - _eventAppliers = eventAppliers; + _eventAppliers = eventAppliers, + _inlineProjectionExecutor = inlineProjectionExecutor; /// Opens a new session for aggregate operations. /// /// Each session is independent and tracks its own loaded aggregates /// and pending events. Sessions should be short-lived. + /// + /// If the store was configured with projections, inline projections + /// will be automatically executed when [ContinuumSession.saveChangesAsync] + /// is called. ContinuumSession openSession() { return SessionImpl( eventStore: _eventStore, serializer: _serializer, aggregateFactories: _aggregateFactories, eventAppliers: _eventAppliers, + inlineProjectionExecutor: _inlineProjectionExecutor, ); } } diff --git a/packages/continuum/lib/src/persistence/projection_event_store.dart b/packages/continuum/lib/src/persistence/projection_event_store.dart new file mode 100644 index 0000000..fb9b826 --- /dev/null +++ b/packages/continuum/lib/src/persistence/projection_event_store.dart @@ -0,0 +1,32 @@ +import 'event_store.dart'; +import 'stored_event.dart'; + +/// Optional extension to [EventStore] for projection support. +/// +/// Stores that support async projections must implement this interface +/// to allow loading events by global sequence position. +/// +/// This enables the projection processor to poll for new events +/// starting from the last processed position. +abstract interface class ProjectionEventStore implements EventStore { + /// Loads events starting from a global sequence position. + /// + /// Returns events with [StoredEvent.globalSequence] >= [fromGlobalSequence], + /// ordered by global sequence, limited to [limit] events. + /// + /// Returns an empty list if no events exist at or after the position. + /// + /// This method is used by the projection processor to poll for new + /// events to process. + Future> loadEventsFromPositionAsync( + int fromGlobalSequence, + int limit, + ); + + /// Returns the current maximum global sequence in the store. + /// + /// Returns `null` if no events have been stored. + /// + /// Useful for determining if there are new events to process. + Future getMaxGlobalSequenceAsync(); +} diff --git a/packages/continuum/lib/src/persistence/session_impl.dart b/packages/continuum/lib/src/persistence/session_impl.dart index 936ae94..b0ac842 100644 --- a/packages/continuum/lib/src/persistence/session_impl.dart +++ b/packages/continuum/lib/src/persistence/session_impl.dart @@ -3,6 +3,7 @@ import '../exceptions/invalid_creation_event_exception.dart'; import '../exceptions/stream_not_found_exception.dart'; import '../exceptions/unsupported_event_exception.dart'; import '../identity/stream_id.dart'; +import '../projections/inline_projection_executor.dart'; import 'atomic_event_store.dart'; import 'event_serializer.dart'; import 'event_sourcing_store.dart'; @@ -51,6 +52,12 @@ final class SessionImpl implements ContinuumSession { final AggregateFactoryRegistry _aggregateFactories; final EventApplierRegistry _eventAppliers; + /// Optional executor for inline projections. + /// + /// If provided, inline projections are executed after events are persisted + /// during [saveChangesAsync]. + final InlineProjectionExecutor? _inlineProjectionExecutor; + /// Tracked streams keyed by stream ID. final Map _streams = {}; @@ -60,10 +67,12 @@ final class SessionImpl implements ContinuumSession { required EventSerializer serializer, required AggregateFactoryRegistry aggregateFactories, required EventApplierRegistry eventAppliers, + InlineProjectionExecutor? inlineProjectionExecutor, }) : _eventStore = eventStore, _serializer = serializer, _aggregateFactories = aggregateFactories, - _eventAppliers = eventAppliers; + _eventAppliers = eventAppliers, + _inlineProjectionExecutor = inlineProjectionExecutor; @override Future loadAsync(StreamId streamId) async { @@ -224,6 +233,9 @@ final class SessionImpl implements ContinuumSession { if (pendingEntries.isEmpty) return; + // Collect all stored events for inline projection execution. + final allStoredEvents = []; + // If the store supports atomic multi-stream writes, use it for true // all-or-nothing semantics when saving across multiple streams. if (pendingEntries.length > 1 && _eventStore is AtomicEventStore) { @@ -241,15 +253,15 @@ final class SessionImpl implements ContinuumSession { for (final event in state.pendingEvents) { final serialized = _serializer.serialize(event); - storedEvents.add( - StoredEvent.fromContinuumEvent( - continuumEvent: event, - streamId: streamId, - version: nextVersion, - eventType: serialized.eventType, - data: serialized.data, - ), + final storedEvent = StoredEvent.fromContinuumEvent( + continuumEvent: event, + streamId: streamId, + version: nextVersion, + eventType: serialized.eventType, + data: serialized.data, ); + storedEvents.add(storedEvent); + allStoredEvents.add(storedEvent); nextVersion++; } @@ -262,6 +274,11 @@ final class SessionImpl implements ContinuumSession { await (_eventStore).appendEventsToStreamsAsync(batches); + // Execute inline projections after successful persistence. + if (_inlineProjectionExecutor != null) { + await _inlineProjectionExecutor.executeAsync(allStoredEvents); + } + for (final entry in pendingEntries) { final streamId = entry.key; final state = entry.value; @@ -287,15 +304,15 @@ final class SessionImpl implements ContinuumSession { for (final event in state.pendingEvents) { final serialized = _serializer.serialize(event); - storedEvents.add( - StoredEvent.fromContinuumEvent( - continuumEvent: event, - streamId: streamId, - version: nextVersion, - eventType: serialized.eventType, - data: serialized.data, - ), + final storedEvent = StoredEvent.fromContinuumEvent( + continuumEvent: event, + streamId: streamId, + version: nextVersion, + eventType: serialized.eventType, + data: serialized.data, ); + storedEvents.add(storedEvent); + allStoredEvents.add(storedEvent); nextVersion++; } @@ -307,6 +324,11 @@ final class SessionImpl implements ContinuumSession { loadedVersion: nextVersion - 1, ); } + + // Execute inline projections after successful persistence. + if (_inlineProjectionExecutor != null) { + await _inlineProjectionExecutor.executeAsync(allStoredEvents); + } } @override diff --git a/packages/continuum/lib/src/projections/async_projection_executor.dart b/packages/continuum/lib/src/projections/async_projection_executor.dart new file mode 100644 index 0000000..a233075 --- /dev/null +++ b/packages/continuum/lib/src/projections/async_projection_executor.dart @@ -0,0 +1,139 @@ +import '../persistence/stored_event.dart'; +import 'projection_position_store.dart'; +import 'projection_registration.dart'; +import 'projection_registry.dart'; + +/// Executes async projections for background processing. +/// +/// The executor processes events through all matching async projections, +/// tracking position for each projection to enable resumption after restarts. +/// +/// Unlike inline projections, async projection failures are logged and +/// can be retried without affecting event persistence. +final class AsyncProjectionExecutor { + /// The registry containing projection registrations. + final ProjectionRegistry _registry; + + /// Store for tracking each projection's processing position. + final ProjectionPositionStore _positionStore; + + /// Creates an async projection executor. + AsyncProjectionExecutor({ + required ProjectionRegistry registry, + required ProjectionPositionStore positionStore, + }) : _registry = registry, + _positionStore = positionStore; + + /// Processes events through all matching async projections. + /// + /// For each event, finds all matching async projections and applies + /// the event to update their read models. Position is updated after + /// each successful projection execution. + /// + /// Events must have a non-null [StoredEvent.globalSequence] for + /// position tracking to work correctly. + /// + /// Returns a [ProcessingResult] indicating success/failure counts. + Future processEventsAsync(List events) async { + if (!_registry.hasAsyncProjections || events.isEmpty) { + return const ProcessingResult(processed: 0, failed: 0); + } + + var processed = 0; + var failed = 0; + + for (final event in events) { + final result = await _processEventAsync(event); + processed += result.processed; + failed += result.failed; + } + + return ProcessingResult(processed: processed, failed: failed); + } + + /// Processes a single event through all matching async projections. + Future _processEventAsync(StoredEvent event) async { + final projections = _registry.asyncProjections; + var processed = 0; + var failed = 0; + + for (final registration in projections) { + try { + await _applyEventToProjectionAsync(registration, event); + processed++; + + // Update position after successful processing. + if (event.globalSequence != null) { + await _positionStore.savePositionAsync( + registration.projectionName, + event.globalSequence!, + ); + } + } catch (error) { + // Log error but continue processing other projections. + // In a production system, this would integrate with logging/monitoring. + failed++; + } + } + + return ProcessingResult(processed: processed, failed: failed); + } + + /// Applies an event to a projection, updating its read model. + Future _applyEventToProjectionAsync( + ProjectionRegistration registration, + StoredEvent event, + ) async { + final projection = registration.projection; + final store = registration.readModelStore; + + // Extract the key for this event. + final key = projection.extractKey(event); + + // Load existing read model or create initial. + var readModel = await store.loadAsync(key); + readModel ??= projection.createInitial(key); + + // Apply the event to produce updated read model. + final updatedReadModel = projection.apply(readModel, event); + + // Persist the updated read model. + await store.saveAsync(key, updatedReadModel); + } + + /// Gets the last processed position for a projection. + /// + /// Returns `null` if the projection has never processed any events. + Future getPositionAsync(String projectionName) async { + return _positionStore.loadPositionAsync(projectionName); + } + + /// Resets a projection's position to process from the beginning. + /// + /// Useful for rebuilding a projection's read models from scratch. + Future resetPositionAsync(String projectionName) async { + final positionStore = _positionStore; + if (positionStore is InMemoryProjectionPositionStore) { + positionStore.remove(projectionName); + } + // For other implementations, setting position to -1 or similar would work. + } +} + +/// Result of processing events through async projections. +final class ProcessingResult { + /// Number of successfully processed projection applications. + final int processed; + + /// Number of failed projection applications. + final int failed; + + /// Creates a processing result. + const ProcessingResult({required this.processed, required this.failed}); + + /// Whether all projection applications succeeded. + bool get isSuccess => failed == 0; + + /// Total number of projection applications attempted. + int get total => processed + failed; +} diff --git a/packages/continuum/lib/src/projections/inline_projection_executor.dart b/packages/continuum/lib/src/projections/inline_projection_executor.dart new file mode 100644 index 0000000..760dc05 --- /dev/null +++ b/packages/continuum/lib/src/projections/inline_projection_executor.dart @@ -0,0 +1,100 @@ +import '../persistence/stored_event.dart'; +import 'projection_registration.dart'; +import 'projection_registry.dart'; + +/// Executes inline projections synchronously during event persistence. +/// +/// The executor is invoked by the session after events are persisted. +/// It applies each event to all matching inline projections, updating +/// read models immediately. +/// +/// If any projection fails, the executor throws an exception, which +/// should cause the caller to abort the transaction. +final class InlineProjectionExecutor { + /// The registry containing projection registrations. + final ProjectionRegistry _registry; + + /// Creates an inline projection executor. + /// + /// The [registry] must contain all projections that should be + /// executed inline. + InlineProjectionExecutor({required ProjectionRegistry registry}) : _registry = registry; + + /// Executes inline projections for the given events. + /// + /// For each event, finds all matching inline projections and applies + /// the event to update their read models. + /// + /// Events are processed in order. For each event: + /// 1. Find all inline projections that handle the event's type + /// 2. For each projection: + /// a. Load the current read model (or create initial if missing) + /// b. Apply the event to produce updated read model + /// c. Save the updated read model + /// + /// Throws if any projection fails. The caller is responsible for + /// handling the failure (e.g., rolling back the event persistence). + Future executeAsync(List events) async { + if (!_registry.hasInlineProjections) { + // Fast path: no inline projections registered. + return; + } + + for (final event in events) { + await _processEventAsync(event); + } + } + + /// Processes a single event through all matching inline projections. + Future _processEventAsync(StoredEvent event) async { + // Look up projections by the stored event type string. + // Since we don't have the runtime Type, we need to check all inline projections. + final projections = _registry.inlineProjections; + + for (final registration in projections) { + // Check if this projection handles this event type by checking the eventType string. + // Since we store event type as string, we need the projection to declare string-based matching + // or we use the registered Type set. For now, we'll iterate and let the projection decide. + if (_shouldProcess(registration, event)) { + await _applyEventToProjectionAsync(registration, event); + } + } + } + + /// Checks if a projection should process the given event. + /// + /// This is a temporary implementation. In practice, projections should + /// declare which event type strings they handle, or the registry should + /// maintain a mapping from event type strings to projections. + bool _shouldProcess( + ProjectionRegistration registration, + StoredEvent event, + ) { + // For now, always process. The projection's apply method should be + // designed to handle only events it cares about. + // A more sophisticated implementation would use event type string matching. + return true; + } + + /// Applies an event to a projection, updating its read model. + Future _applyEventToProjectionAsync( + ProjectionRegistration registration, + StoredEvent event, + ) async { + final projection = registration.projection; + final store = registration.readModelStore; + + // Extract the key for this event. + final key = projection.extractKey(event); + + // Load existing read model or create initial. + var readModel = await store.loadAsync(key); + readModel ??= projection.createInitial(key); + + // Apply the event to produce updated read model. + final updatedReadModel = projection.apply(readModel, event); + + // Persist the updated read model. + await store.saveAsync(key, updatedReadModel); + } +} diff --git a/packages/continuum/lib/src/projections/multi_stream_projection.dart b/packages/continuum/lib/src/projections/multi_stream_projection.dart new file mode 100644 index 0000000..1fb4a12 --- /dev/null +++ b/packages/continuum/lib/src/projections/multi_stream_projection.dart @@ -0,0 +1,61 @@ +import '../persistence/stored_event.dart'; +import 'projection.dart'; + +/// Projection that builds a read model from events across multiple streams. +/// +/// Multi-stream projections aggregate events from many streams into read models +/// grouped by a projection-defined key. Events are processed in global sequence +/// order, providing cross-stream ordering. +/// +/// Use cases: +/// - Cross-aggregate views +/// - Dashboards and statistics +/// - Search indexes +/// - Counters across entities +/// +/// Example: +/// ```dart +/// class LibraryStatisticsProjection extends MultiStreamProjection { +/// @override +/// Set get handledEventTypes => {AudioFileAdded, AudioFileRemoved}; +/// +/// @override +/// String get projectionName => 'library-statistics'; +/// +/// @override +/// String extractKey(StoredEvent event) { +/// // Group by library ID from event data +/// return event.data['libraryId'] as String; +/// } +/// +/// @override +/// LibraryStats createInitial(String libraryId) => +/// LibraryStats(libraryId: libraryId, fileCount: 0); +/// +/// @override +/// LibraryStats apply(LibraryStats current, StoredEvent event) { +/// // Update statistics based on event type +/// } +/// } +/// ``` +abstract class MultiStreamProjection extends Projection { + /// Extracts the grouping key from the event. + /// + /// Multiple streams may contribute events to the same key, + /// enabling cross-aggregate read models. + @override + TKey extractKey(StoredEvent event); + + /// Creates the initial read model state for a new key. + /// + /// Called when processing the first event for a given key. + @override + TReadModel createInitial(TKey key); + + /// Applies an event to update the read model. + /// + /// Events arrive in global sequence order, not per-stream order. + /// Implementations must handle events from multiple streams. + @override + TReadModel apply(TReadModel current, StoredEvent event); +} diff --git a/packages/continuum/lib/src/projections/projection.dart b/packages/continuum/lib/src/projections/projection.dart new file mode 100644 index 0000000..23506af --- /dev/null +++ b/packages/continuum/lib/src/projections/projection.dart @@ -0,0 +1,43 @@ +import '../persistence/stored_event.dart'; + +/// Base abstraction for projections that transform events into read models. +/// +/// Projections are pure event consumers that: +/// - Do NOT load aggregates +/// - Do NOT issue commands +/// - Do NOT have side effects beyond updating the read model +/// +/// Subclasses define the specific read model type and key extraction logic. +abstract class Projection { + /// The set of event types this projection handles. + /// + /// Only events with runtime types in this set will be routed to this projection. + /// Subclasses must override to declare their event dependencies. + Set get handledEventTypes; + + /// A unique name identifying this projection. + /// + /// Used for position tracking in async projections and for debugging. + String get projectionName; + + /// Extracts the key used to identify the read model instance. + /// + /// For single-stream projections, this is typically the stream ID. + /// For multi-stream projections, this is a grouping key derived from event data. + TKey extractKey(StoredEvent event); + + /// Creates the initial read model state for a new key. + /// + /// Called when processing the first event for a given key. + TReadModel createInitial(TKey key); + + /// Applies an event to update the read model. + /// + /// Returns the updated read model. Implementations must be pure and + /// deterministic—the same event applied to the same read model must + /// always produce the same result. + TReadModel apply(TReadModel current, StoredEvent event); + + /// Checks whether this projection handles the given event type. + bool handles(Type eventType) => handledEventTypes.contains(eventType); +} diff --git a/packages/continuum/lib/src/projections/projection_lifecycle.dart b/packages/continuum/lib/src/projections/projection_lifecycle.dart new file mode 100644 index 0000000..be4d4b2 --- /dev/null +++ b/packages/continuum/lib/src/projections/projection_lifecycle.dart @@ -0,0 +1,23 @@ +/// Defines the execution lifecycle for a projection. +/// +/// The lifecycle determines when and how a projection is executed +/// in response to new events. +enum ProjectionLifecycle { + /// Inline projections are executed synchronously during event persistence. + /// + /// Characteristics: + /// - Part of the same logical unit of work as the event append + /// - Failure aborts the entire event append operation + /// - Read models are immediately consistent after save completes + /// - Increases write latency but provides strong consistency + inline, + + /// Async projections are executed by a background processor. + /// + /// Characteristics: + /// - Event append completes immediately without waiting for projection + /// - Processed asynchronously by the projection processor + /// - Eventually consistent—read models may lag behind writes + /// - Lower write latency but temporary inconsistency is possible + async, +} diff --git a/packages/continuum/lib/src/projections/projection_position_store.dart b/packages/continuum/lib/src/projections/projection_position_store.dart new file mode 100644 index 0000000..3624347 --- /dev/null +++ b/packages/continuum/lib/src/projections/projection_position_store.dart @@ -0,0 +1,54 @@ +/// Abstraction for tracking async projection processing positions. +/// +/// Each async projection tracks the global sequence number of the last +/// event it successfully processed. This enables resumption after restarts +/// and ensures no events are missed or processed twice. +abstract interface class ProjectionPositionStore { + /// Loads the last processed position for a projection. + /// + /// Returns the global sequence number of the last successfully processed + /// event, or `null` if the projection has never processed any events + /// (indicating it should start from the beginning). + Future loadPositionAsync(String projectionName); + + /// Saves the current position for a projection. + /// + /// Should be called after successfully processing an event to record + /// progress. The position is the global sequence number of the + /// processed event. + Future savePositionAsync(String projectionName, int position); +} + +/// In-memory implementation of [ProjectionPositionStore] for testing. +/// +/// Stores positions in a simple map. Data is lost when the store +/// instance is garbage collected. +final class InMemoryProjectionPositionStore implements ProjectionPositionStore { + /// Internal storage map. + final Map _positions = {}; + + @override + Future loadPositionAsync(String projectionName) async { + return _positions[projectionName]; + } + + @override + Future savePositionAsync(String projectionName, int position) async { + _positions[projectionName] = position; + } + + /// Returns the number of tracked projections. + /// + /// Useful for testing to verify storage state. + int get length => _positions.length; + + /// Clears all stored positions. + /// + /// Useful for testing to reset state between tests. + void clear() => _positions.clear(); + + /// Removes the position for a specific projection. + /// + /// Useful for testing projection rebuild scenarios. + void remove(String projectionName) => _positions.remove(projectionName); +} diff --git a/packages/continuum/lib/src/projections/projection_processor.dart b/packages/continuum/lib/src/projections/projection_processor.dart new file mode 100644 index 0000000..95cb18a --- /dev/null +++ b/packages/continuum/lib/src/projections/projection_processor.dart @@ -0,0 +1,188 @@ +import 'dart:async'; + +import '../persistence/stored_event.dart'; +import 'async_projection_executor.dart'; +import 'projection_position_store.dart'; + +/// Abstraction for background projection processing. +/// +/// The processor polls for new events and applies them to async projections. +/// It manages its own lifecycle and can be started/stopped as needed. +abstract interface class ProjectionProcessor { + /// Starts the background projection processor. + /// + /// After calling this, the processor will continuously poll for + /// new events and apply them to registered async projections. + Future startAsync(); + + /// Stops the background projection processor. + /// + /// Completes any in-progress work before stopping. + /// After calling this, no more events will be processed until + /// [startAsync] is called again. + Future stopAsync(); + + /// Processes a single batch of events. + /// + /// Useful for manual control of projection processing, testing, + /// or when continuous background processing is not desired. + /// + /// Returns the result of processing the batch. + Future processBatchAsync(); +} + +/// Function type for loading events from a position. +/// +/// Used to decouple the processor from the event store implementation. +typedef EventLoader = + Future> Function( + int fromGlobalSequence, + int limit, + ); + +/// Polling-based implementation of [ProjectionProcessor]. +/// +/// Periodically polls for new events using a configurable interval +/// and processes them through the async projection executor. +final class PollingProjectionProcessor implements ProjectionProcessor { + /// Default batch size for loading events. + static const int defaultBatchSize = 100; + + /// Default polling interval. + static const Duration defaultPollingInterval = Duration(seconds: 1); + + /// The async projection executor. + final AsyncProjectionExecutor _executor; + + /// The position store for tracking overall processing position. + final ProjectionPositionStore _positionStore; + + /// Function to load events from a position. + final EventLoader _eventLoader; + + /// Batch size for loading events. + final int _batchSize; + + /// Interval between polling attempts. + final Duration _pollingInterval; + + /// Key used to track the processor's overall position. + final String _positionKey; + + /// Timer for periodic polling. + Timer? _timer; + + /// Flag indicating whether the processor is running. + bool _isRunning = false; + + /// Flag to prevent concurrent batch processing. + bool _isProcessing = false; + + /// Creates a polling projection processor. + /// + /// Parameters: + /// - [executor]: The async projection executor to use. + /// - [positionStore]: Store for tracking processing position. + /// - [eventLoader]: Function to load events from a global sequence. + /// - [batchSize]: Number of events to load per batch. + /// - [pollingInterval]: Time between polling attempts. + /// - [positionKey]: Key for storing the processor's position. + PollingProjectionProcessor({ + required AsyncProjectionExecutor executor, + required ProjectionPositionStore positionStore, + required EventLoader eventLoader, + int batchSize = defaultBatchSize, + Duration pollingInterval = defaultPollingInterval, + String positionKey = '_processor_position', + }) : _executor = executor, + _positionStore = positionStore, + _eventLoader = eventLoader, + _batchSize = batchSize, + _pollingInterval = pollingInterval, + _positionKey = positionKey; + + /// Whether the processor is currently running. + bool get isRunning => _isRunning; + + @override + Future startAsync() async { + if (_isRunning) { + return; // Already running. + } + + _isRunning = true; + + // Start periodic polling. + _timer = Timer.periodic(_pollingInterval, (_) { + // Fire and forget - don't await to avoid blocking the timer. + _pollAsync(); + }); + + // Process immediately on start. + await _pollAsync(); + } + + @override + Future stopAsync() async { + _isRunning = false; + _timer?.cancel(); + _timer = null; + + // Wait for any in-progress processing to complete. + while (_isProcessing) { + await Future.delayed(const Duration(milliseconds: 10)); + } + } + + @override + Future processBatchAsync() async { + if (_isProcessing) { + // Already processing, return empty result. + return const ProcessingResult(processed: 0, failed: 0); + } + + _isProcessing = true; + try { + return await _processBatchInternalAsync(); + } finally { + _isProcessing = false; + } + } + + /// Internal polling method. + Future _pollAsync() async { + if (!_isRunning || _isProcessing) { + return; + } + + await processBatchAsync(); + } + + /// Processes a batch of events. + Future _processBatchInternalAsync() async { + // Load current position (null means start from beginning). + final lastPosition = await _positionStore.loadPositionAsync(_positionKey); + final fromPosition = (lastPosition ?? -1) + 1; + + // Load next batch of events. + final events = await _eventLoader(fromPosition, _batchSize); + + if (events.isEmpty) { + return const ProcessingResult(processed: 0, failed: 0); + } + + // Process events through the executor. + final result = await _executor.processEventsAsync(events); + + // Update overall processor position to the last event's global sequence. + final lastEvent = events.last; + if (lastEvent.globalSequence != null) { + await _positionStore.savePositionAsync( + _positionKey, + lastEvent.globalSequence!, + ); + } + + return result; + } +} diff --git a/packages/continuum/lib/src/projections/projection_registration.dart b/packages/continuum/lib/src/projections/projection_registration.dart new file mode 100644 index 0000000..5a3c524 --- /dev/null +++ b/packages/continuum/lib/src/projections/projection_registration.dart @@ -0,0 +1,38 @@ +import 'projection.dart'; +import 'projection_lifecycle.dart'; +import 'read_model_store.dart'; + +/// Holds a projection along with its configuration metadata. +/// +/// This class bundles a projection instance with its execution lifecycle +/// and read model storage, enabling the registry and executors to +/// correctly route and process events. +final class ProjectionRegistration { + /// The projection instance that processes events. + final Projection projection; + + /// The execution lifecycle (inline or async). + final ProjectionLifecycle lifecycle; + + /// The store for persisting this projection's read models. + final ReadModelStore readModelStore; + + /// Creates a projection registration. + /// + /// All parameters are required—projections must have a defined lifecycle + /// and storage to function correctly. + const ProjectionRegistration({ + required this.projection, + required this.lifecycle, + required this.readModelStore, + }); + + /// The unique name identifying this projection. + String get projectionName => projection.projectionName; + + /// The set of event types this projection handles. + Set get handledEventTypes => projection.handledEventTypes; + + /// Checks whether this projection handles the given event type. + bool handles(Type eventType) => projection.handles(eventType); +} diff --git a/packages/continuum/lib/src/projections/projection_registry.dart b/packages/continuum/lib/src/projections/projection_registry.dart new file mode 100644 index 0000000..cb8f9c2 --- /dev/null +++ b/packages/continuum/lib/src/projections/projection_registry.dart @@ -0,0 +1,148 @@ +import 'projection.dart'; +import 'projection_lifecycle.dart'; +import 'projection_registration.dart'; +import 'read_model_store.dart'; + +/// Central registry for all projections in the system. +/// +/// The registry maintains a mapping from event types to projections, +/// enabling efficient routing of events to their handlers. Projections +/// are registered with either inline or async lifecycle. +/// +/// Example: +/// ```dart +/// final registry = ProjectionRegistry(); +/// +/// registry.registerInline( +/// userProfileProjection, +/// userProfileStore, +/// ); +/// +/// registry.registerAsync( +/// statisticsProjection, +/// statisticsStore, +/// ); +/// ``` +final class ProjectionRegistry { + /// All registered projections indexed by name. + final Map> _registrations = {}; + + /// Index of event type → projection names for fast lookup. + final Map> _eventTypeIndex = {}; + + /// Registers a projection for inline (synchronous) execution. + /// + /// Inline projections are executed during `saveChangesAsync()` as part + /// of the same logical unit of work. Failures abort the event append. + /// + /// Throws [StateError] if a projection with the same name is already registered. + void registerInline( + Projection projection, + ReadModelStore readModelStore, + ) { + _register( + projection: projection, + readModelStore: readModelStore, + lifecycle: ProjectionLifecycle.inline, + ); + } + + /// Registers a projection for async (background) execution. + /// + /// Async projections are executed by the background projection processor. + /// Event appends complete immediately without waiting for the projection. + /// + /// Throws [StateError] if a projection with the same name is already registered. + void registerAsync( + Projection projection, + ReadModelStore readModelStore, + ) { + _register( + projection: projection, + readModelStore: readModelStore, + lifecycle: ProjectionLifecycle.async, + ); + } + + /// Internal registration method. + void _register({ + required Projection projection, + required ReadModelStore readModelStore, + required ProjectionLifecycle lifecycle, + }) { + final name = projection.projectionName; + + // Prevent duplicate registration. + if (_registrations.containsKey(name)) { + throw StateError( + 'Projection "$name" is already registered. ' + 'Each projection must have a unique name.', + ); + } + + // Store the registration (cast to Object to store heterogeneous types). + final registration = ProjectionRegistration( + projection: projection, + lifecycle: lifecycle, + readModelStore: readModelStore, + ); + _registrations[name] = registration as ProjectionRegistration; + + // Index by event type for fast lookup. + for (final eventType in projection.handledEventTypes) { + _eventTypeIndex.putIfAbsent(eventType, () => {}).add(name); + } + } + + /// Returns all inline projections that handle the given event type. + List> getInlineProjectionsForEventType( + Type eventType, + ) { + return _getProjectionsForEventType(eventType, ProjectionLifecycle.inline); + } + + /// Returns all async projections that handle the given event type. + List> getAsyncProjectionsForEventType( + Type eventType, + ) { + return _getProjectionsForEventType(eventType, ProjectionLifecycle.async); + } + + /// Internal lookup method. + List> _getProjectionsForEventType( + Type eventType, + ProjectionLifecycle lifecycle, + ) { + final names = _eventTypeIndex[eventType]; + if (names == null || names.isEmpty) { + return const []; + } + + return names.map((name) => _registrations[name]).whereType>().where((reg) => reg.lifecycle == lifecycle).toList(); + } + + /// Returns all inline projection registrations. + List> get inlineProjections { + return _registrations.values.where((reg) => reg.lifecycle == ProjectionLifecycle.inline).toList(); + } + + /// Returns all async projection registrations. + List> get asyncProjections { + return _registrations.values.where((reg) => reg.lifecycle == ProjectionLifecycle.async).toList(); + } + + /// Returns the total number of registered projections. + int get length => _registrations.length; + + /// Returns whether any projections are registered. + bool get isEmpty => _registrations.isEmpty; + + /// Returns whether any projections are registered. + bool get isNotEmpty => _registrations.isNotEmpty; + + /// Returns whether any inline projections are registered. + bool get hasInlineProjections => inlineProjections.isNotEmpty; + + /// Returns whether any async projections are registered. + bool get hasAsyncProjections => asyncProjections.isNotEmpty; +} diff --git a/packages/continuum/lib/src/projections/read_model_store.dart b/packages/continuum/lib/src/projections/read_model_store.dart new file mode 100644 index 0000000..47d5f10 --- /dev/null +++ b/packages/continuum/lib/src/projections/read_model_store.dart @@ -0,0 +1,59 @@ +/// Abstraction for persisting projection read models. +/// +/// Implementations provide the storage mechanism for read models, +/// whether in-memory, file-based, or database-backed. +/// +/// The type parameters define: +/// - [TReadModel]: The read model type being stored +/// - [TKey]: The key type used to identify read model instances +abstract interface class ReadModelStore { + /// Loads a read model by its key. + /// + /// Returns the read model if found, or `null` if no read model exists + /// for the given key. + Future loadAsync(TKey key); + + /// Saves a read model for the given key. + /// + /// If a read model already exists for the key, it is replaced. + Future saveAsync(TKey key, TReadModel readModel); + + /// Deletes the read model for the given key. + /// + /// If no read model exists for the key, this is a no-op. + Future deleteAsync(TKey key); +} + +/// In-memory implementation of [ReadModelStore] for testing. +/// +/// Stores read models in a simple map. Data is lost when the +/// store instance is garbage collected. +final class InMemoryReadModelStore implements ReadModelStore { + /// Internal storage map. + final Map _storage = {}; + + @override + Future loadAsync(TKey key) async { + return _storage[key]; + } + + @override + Future saveAsync(TKey key, TReadModel readModel) async { + _storage[key] = readModel; + } + + @override + Future deleteAsync(TKey key) async { + _storage.remove(key); + } + + /// Returns the number of stored read models. + /// + /// Useful for testing to verify storage state. + int get length => _storage.length; + + /// Clears all stored read models. + /// + /// Useful for testing to reset state between tests. + void clear() => _storage.clear(); +} diff --git a/packages/continuum/lib/src/projections/single_stream_projection.dart b/packages/continuum/lib/src/projections/single_stream_projection.dart new file mode 100644 index 0000000..fac8d2c --- /dev/null +++ b/packages/continuum/lib/src/projections/single_stream_projection.dart @@ -0,0 +1,54 @@ +import '../identity/stream_id.dart'; +import '../persistence/stored_event.dart'; +import 'projection.dart'; + +/// Projection that builds a read model from a single event stream. +/// +/// Single-stream projections create one read model instance per aggregate, +/// identified by the stream's [StreamId]. Events are processed in per-stream +/// version order, providing deterministic ordering. +/// +/// Use cases: +/// - Aggregate summaries +/// - Per-entity query models +/// - State snapshots +/// +/// Example: +/// ```dart +/// class UserProfileProjection extends SingleStreamProjection { +/// @override +/// Set get handledEventTypes => {UserRegistered, ProfileUpdated}; +/// +/// @override +/// String get projectionName => 'user-profile'; +/// +/// @override +/// UserProfile createInitial(StreamId streamId) => +/// UserProfile(id: streamId.value); +/// +/// @override +/// UserProfile apply(UserProfile current, StoredEvent event) { +/// // Apply event to update the profile +/// } +/// } +/// ``` +abstract class SingleStreamProjection extends Projection { + /// Extracts the stream ID from the event. + /// + /// For single-stream projections, the key is always the event's stream ID, + /// ensuring one read model per aggregate instance. + @override + StreamId extractKey(StoredEvent event) => event.streamId; + + /// Creates the initial read model state for a new stream. + /// + /// Called when processing the first event for a given stream ID. + @override + TReadModel createInitial(StreamId streamId); + + /// Applies an event to update the read model. + /// + /// Events arrive in per-stream version order (0, 1, 2, ...). + @override + TReadModel apply(TReadModel current, StoredEvent event); +} diff --git a/packages/continuum/test/projections/async_projection_executor_test.dart b/packages/continuum/test/projections/async_projection_executor_test.dart new file mode 100644 index 0000000..b5f43e2 --- /dev/null +++ b/packages/continuum/test/projections/async_projection_executor_test.dart @@ -0,0 +1,293 @@ +import 'package:continuum/continuum.dart'; +import 'package:test/test.dart'; +import 'package:zooper_flutter_core/zooper_flutter_core.dart'; + +void main() { + group('AsyncProjectionExecutor', () { + late ProjectionRegistry registry; + late InMemoryProjectionPositionStore positionStore; + late InMemoryReadModelStore<_CounterReadModel, StreamId> readModelStore; + late AsyncProjectionExecutor executor; + + setUp(() { + registry = ProjectionRegistry(); + positionStore = InMemoryProjectionPositionStore(); + readModelStore = InMemoryReadModelStore<_CounterReadModel, StreamId>(); + }); + + test('processEventsAsync does nothing when no projections registered', () async { + executor = AsyncProjectionExecutor( + registry: registry, + positionStore: positionStore, + ); + + final event = _createEvent(streamId: 'stream-1', globalSequence: 1); + final result = await executor.processEventsAsync([event]); + + expect(result.processed, equals(0)); + expect(result.failed, equals(0)); + expect(result.isSuccess, isTrue); + }); + + test('processEventsAsync does nothing with empty event list', () async { + final projection = _CounterProjection(); + registry.registerAsync(projection, readModelStore); + executor = AsyncProjectionExecutor( + registry: registry, + positionStore: positionStore, + ); + + final result = await executor.processEventsAsync([]); + + expect(result.processed, equals(0)); + expect(result.total, equals(0)); + }); + + test('processEventsAsync creates initial read model', () async { + final projection = _CounterProjection(); + registry.registerAsync(projection, readModelStore); + executor = AsyncProjectionExecutor( + registry: registry, + positionStore: positionStore, + ); + + final event = _createEvent(streamId: 'stream-1', globalSequence: 1); + await executor.processEventsAsync([event]); + + final readModel = await readModelStore.loadAsync(const StreamId('stream-1')); + expect(readModel, isNotNull); + expect(readModel!.count, equals(1)); + }); + + test('processEventsAsync updates existing read model', () async { + final projection = _CounterProjection(); + registry.registerAsync(projection, readModelStore); + executor = AsyncProjectionExecutor( + registry: registry, + positionStore: positionStore, + ); + + // Pre-populate + await readModelStore.saveAsync( + const StreamId('stream-1'), + _CounterReadModel(streamId: 'stream-1', count: 10), + ); + + final event = _createEvent(streamId: 'stream-1', globalSequence: 5); + await executor.processEventsAsync([event]); + + final readModel = await readModelStore.loadAsync(const StreamId('stream-1')); + expect(readModel!.count, equals(11)); + }); + + test('processEventsAsync tracks position after success', () async { + final projection = _CounterProjection(); + registry.registerAsync(projection, readModelStore); + executor = AsyncProjectionExecutor( + registry: registry, + positionStore: positionStore, + ); + + final events = [ + _createEvent(streamId: 'stream-1', globalSequence: 10), + _createEvent(streamId: 'stream-1', globalSequence: 11), + _createEvent(streamId: 'stream-1', globalSequence: 12), + ]; + + await executor.processEventsAsync(events); + + final position = await positionStore.loadPositionAsync('counter'); + expect(position, equals(12)); + }); + + test('processEventsAsync skips inline projections', () async { + final inlineProjection = _CounterProjection('inline'); + final asyncProjection = _CounterProjection('async'); + final inlineStore = InMemoryReadModelStore<_CounterReadModel, StreamId>(); + + registry.registerInline(inlineProjection, inlineStore); + registry.registerAsync(asyncProjection, readModelStore); + executor = AsyncProjectionExecutor( + registry: registry, + positionStore: positionStore, + ); + + final event = _createEvent(streamId: 'stream-1', globalSequence: 1); + await executor.processEventsAsync([event]); + + expect(inlineStore.length, equals(0)); + expect(readModelStore.length, equals(1)); + }); + + test('processEventsAsync continues after projection failure', () async { + final failingProjection = _FailingProjection(); + final workingProjection = _CounterProjection('working'); + final failingStore = InMemoryReadModelStore(); + final workingStore = InMemoryReadModelStore<_CounterReadModel, StreamId>(); + + registry.registerAsync(failingProjection, failingStore); + registry.registerAsync(workingProjection, workingStore); + executor = AsyncProjectionExecutor( + registry: registry, + positionStore: positionStore, + ); + + final event = _createEvent(streamId: 'stream-1', globalSequence: 1); + final result = await executor.processEventsAsync([event]); + + // Failing projection failed, working projection succeeded. + expect(result.failed, equals(1)); + expect(result.processed, equals(1)); + expect(result.isSuccess, isFalse); + + // Working projection should have updated. + expect(workingStore.length, equals(1)); + }); + + test('processEventsAsync returns correct counts for multiple events', () async { + final projection = _CounterProjection(); + registry.registerAsync(projection, readModelStore); + executor = AsyncProjectionExecutor( + registry: registry, + positionStore: positionStore, + ); + + final events = [ + _createEvent(streamId: 's1', globalSequence: 1), + _createEvent(streamId: 's2', globalSequence: 2), + _createEvent(streamId: 's3', globalSequence: 3), + ]; + + final result = await executor.processEventsAsync(events); + + expect(result.processed, equals(3)); + expect(result.failed, equals(0)); + expect(result.total, equals(3)); + }); + + test('getPositionAsync returns last processed position', () async { + final projection = _CounterProjection(); + registry.registerAsync(projection, readModelStore); + executor = AsyncProjectionExecutor( + registry: registry, + positionStore: positionStore, + ); + + final event = _createEvent(streamId: 'stream-1', globalSequence: 42); + await executor.processEventsAsync([event]); + + final position = await executor.getPositionAsync('counter'); + expect(position, equals(42)); + }); + + test('getPositionAsync returns null for unprocessed projection', () async { + executor = AsyncProjectionExecutor( + registry: registry, + positionStore: positionStore, + ); + + final position = await executor.getPositionAsync('unknown'); + expect(position, isNull); + }); + + test('resetPositionAsync clears projection position', () async { + await positionStore.savePositionAsync('counter', 100); + executor = AsyncProjectionExecutor( + registry: registry, + positionStore: positionStore, + ); + + await executor.resetPositionAsync('counter'); + + final position = await positionStore.loadPositionAsync('counter'); + expect(position, isNull); + }); + }); + + group('ProcessingResult', () { + test('isSuccess returns true when no failures', () { + const result = ProcessingResult(processed: 5, failed: 0); + expect(result.isSuccess, isTrue); + }); + + test('isSuccess returns false when any failures', () { + const result = ProcessingResult(processed: 3, failed: 2); + expect(result.isSuccess, isFalse); + }); + + test('total returns sum of processed and failed', () { + const result = ProcessingResult(processed: 7, failed: 3); + expect(result.total, equals(10)); + }); + }); +} + +// --- Test Fixtures --- + +int _eventCounter = 0; + +StoredEvent _createEvent({ + required String streamId, + int? globalSequence, +}) { + return StoredEvent( + eventId: EventId('evt-${_eventCounter++}'), + streamId: StreamId(streamId), + version: 0, + eventType: 'test.event', + data: const {}, + occurredOn: DateTime.now(), + metadata: const {}, + globalSequence: globalSequence, + ); +} + +class _CounterReadModel { + final String streamId; + final int count; + + _CounterReadModel({required this.streamId, required this.count}); +} + +class _CounterProjection extends SingleStreamProjection<_CounterReadModel> { + final String _name; + + _CounterProjection([this._name = 'counter']); + + @override + Set get handledEventTypes => {_TestEvent}; + + @override + String get projectionName => _name; + + @override + _CounterReadModel createInitial(StreamId streamId) { + return _CounterReadModel(streamId: streamId.value, count: 0); + } + + @override + _CounterReadModel apply(_CounterReadModel current, StoredEvent event) { + return _CounterReadModel( + streamId: current.streamId, + count: current.count + 1, + ); + } +} + +class _TestEvent {} + +class _FailingProjection extends SingleStreamProjection { + @override + Set get handledEventTypes => {_TestEvent}; + + @override + String get projectionName => 'failing'; + + @override + int createInitial(StreamId streamId) => 0; + + @override + int apply(int current, StoredEvent event) { + throw StateError('Intentional failure'); + } +} diff --git a/packages/continuum/test/projections/inline_projection_executor_test.dart b/packages/continuum/test/projections/inline_projection_executor_test.dart new file mode 100644 index 0000000..bf1f66a --- /dev/null +++ b/packages/continuum/test/projections/inline_projection_executor_test.dart @@ -0,0 +1,215 @@ +import 'package:continuum/continuum.dart'; +import 'package:test/test.dart'; +import 'package:zooper_flutter_core/zooper_flutter_core.dart'; + +void main() { + group('InlineProjectionExecutor', () { + late ProjectionRegistry registry; + late InMemoryReadModelStore<_CounterReadModel, StreamId> store; + late InlineProjectionExecutor executor; + + setUp(() { + registry = ProjectionRegistry(); + store = InMemoryReadModelStore<_CounterReadModel, StreamId>(); + }); + + test('executeAsync does nothing when no projections registered', () async { + executor = InlineProjectionExecutor(registry: registry); + final event = _createEvent(streamId: 'stream-1'); + + // Should not throw + await executor.executeAsync([event]); + + expect(store.length, equals(0)); + }); + + test('executeAsync creates initial read model for new stream', () async { + final projection = _CounterProjection(); + registry.registerInline(projection, store); + executor = InlineProjectionExecutor(registry: registry); + + final event = _createEvent(streamId: 'stream-1'); + await executor.executeAsync([event]); + + final readModel = await store.loadAsync(const StreamId('stream-1')); + expect(readModel, isNotNull); + expect(readModel!.count, equals(1)); + }); + + test('executeAsync updates existing read model', () async { + final projection = _CounterProjection(); + registry.registerInline(projection, store); + executor = InlineProjectionExecutor(registry: registry); + + // Pre-populate read model + await store.saveAsync( + const StreamId('stream-1'), + _CounterReadModel(streamId: 'stream-1', count: 5), + ); + + final event = _createEvent(streamId: 'stream-1'); + await executor.executeAsync([event]); + + final readModel = await store.loadAsync(const StreamId('stream-1')); + expect(readModel!.count, equals(6)); + }); + + test('executeAsync processes multiple events in order', () async { + final projection = _CounterProjection(); + registry.registerInline(projection, store); + executor = InlineProjectionExecutor(registry: registry); + + final events = [ + _createEvent(streamId: 'stream-1', version: 0), + _createEvent(streamId: 'stream-1', version: 1), + _createEvent(streamId: 'stream-1', version: 2), + ]; + + await executor.executeAsync(events); + + final readModel = await store.loadAsync(const StreamId('stream-1')); + expect(readModel!.count, equals(3)); + }); + + test('executeAsync processes events for multiple streams', () async { + final projection = _CounterProjection(); + registry.registerInline(projection, store); + executor = InlineProjectionExecutor(registry: registry); + + final events = [ + _createEvent(streamId: 'stream-1'), + _createEvent(streamId: 'stream-2'), + _createEvent(streamId: 'stream-1'), + ]; + + await executor.executeAsync(events); + + final readModel1 = await store.loadAsync(const StreamId('stream-1')); + final readModel2 = await store.loadAsync(const StreamId('stream-2')); + + expect(readModel1!.count, equals(2)); + expect(readModel2!.count, equals(1)); + }); + + test('executeAsync applies to multiple projections', () async { + final projection1 = _CounterProjection('counter-1'); + final projection2 = _CounterProjection('counter-2'); + final store1 = InMemoryReadModelStore<_CounterReadModel, StreamId>(); + final store2 = InMemoryReadModelStore<_CounterReadModel, StreamId>(); + + registry.registerInline(projection1, store1); + registry.registerInline(projection2, store2); + executor = InlineProjectionExecutor(registry: registry); + + final event = _createEvent(streamId: 'stream-1'); + await executor.executeAsync([event]); + + final readModel1 = await store1.loadAsync(const StreamId('stream-1')); + final readModel2 = await store2.loadAsync(const StreamId('stream-1')); + + expect(readModel1!.count, equals(1)); + expect(readModel2!.count, equals(1)); + }); + + test('executeAsync skips async projections', () async { + final inlineProjection = _CounterProjection('inline'); + final asyncProjection = _CounterProjection('async'); + final inlineStore = InMemoryReadModelStore<_CounterReadModel, StreamId>(); + final asyncStore = InMemoryReadModelStore<_CounterReadModel, StreamId>(); + + registry.registerInline(inlineProjection, inlineStore); + registry.registerAsync(asyncProjection, asyncStore); + executor = InlineProjectionExecutor(registry: registry); + + final event = _createEvent(streamId: 'stream-1'); + await executor.executeAsync([event]); + + expect(inlineStore.length, equals(1)); + expect(asyncStore.length, equals(0)); + }); + + test('executeAsync propagates projection errors', () async { + final failingProjection = _FailingProjection(); + final failingStore = InMemoryReadModelStore(); + + registry.registerInline(failingProjection, failingStore); + executor = InlineProjectionExecutor(registry: registry); + + final event = _createEvent(streamId: 'stream-1'); + + await expectLater( + executor.executeAsync([event]), + throwsA(isA()), + ); + }); + }); +} + +// --- Test Fixtures --- + +int _eventCounter = 0; + +StoredEvent _createEvent({ + required String streamId, + int version = 0, +}) { + return StoredEvent( + eventId: EventId('evt-${_eventCounter++}'), + streamId: StreamId(streamId), + version: version, + eventType: 'test.counter_incremented', + data: const {}, + occurredOn: DateTime.now(), + metadata: const {}, + ); +} + +class _CounterReadModel { + final String streamId; + final int count; + + _CounterReadModel({required this.streamId, required this.count}); +} + +class _CounterProjection extends SingleStreamProjection<_CounterReadModel> { + final String _name; + + _CounterProjection([this._name = 'counter']); + + @override + Set get handledEventTypes => {_TestEvent}; + + @override + String get projectionName => _name; + + @override + _CounterReadModel createInitial(StreamId streamId) { + return _CounterReadModel(streamId: streamId.value, count: 0); + } + + @override + _CounterReadModel apply(_CounterReadModel current, StoredEvent event) { + return _CounterReadModel( + streamId: current.streamId, + count: current.count + 1, + ); + } +} + +class _TestEvent {} + +class _FailingProjection extends SingleStreamProjection { + @override + Set get handledEventTypes => {_TestEvent}; + + @override + String get projectionName => 'failing'; + + @override + int createInitial(StreamId streamId) => 0; + + @override + int apply(int current, StoredEvent event) { + throw StateError('Intentional failure for testing'); + } +} diff --git a/packages/continuum/test/projections/projection_position_store_test.dart b/packages/continuum/test/projections/projection_position_store_test.dart new file mode 100644 index 0000000..0f8d662 --- /dev/null +++ b/packages/continuum/test/projections/projection_position_store_test.dart @@ -0,0 +1,98 @@ +import 'package:continuum/src/projections/projection_position_store.dart'; +import 'package:test/test.dart'; + +void main() { + group('InMemoryProjectionPositionStore', () { + late InMemoryProjectionPositionStore store; + + setUp(() { + store = InMemoryProjectionPositionStore(); + }); + + test('loadPositionAsync returns null for new projection', () async { + final position = await store.loadPositionAsync('new-projection'); + + expect(position, isNull); + }); + + test('savePositionAsync stores position', () async { + await store.savePositionAsync('projection-1', 42); + + final position = await store.loadPositionAsync('projection-1'); + + expect(position, equals(42)); + }); + + test('savePositionAsync overwrites existing position', () async { + await store.savePositionAsync('projection-1', 10); + await store.savePositionAsync('projection-1', 50); + + final position = await store.loadPositionAsync('projection-1'); + + expect(position, equals(50)); + }); + + test('stores positions for multiple projections independently', () async { + await store.savePositionAsync('projection-a', 100); + await store.savePositionAsync('projection-b', 200); + await store.savePositionAsync('projection-c', 300); + + expect(await store.loadPositionAsync('projection-a'), equals(100)); + expect(await store.loadPositionAsync('projection-b'), equals(200)); + expect(await store.loadPositionAsync('projection-c'), equals(300)); + }); + + test('length returns number of tracked projections', () async { + expect(store.length, equals(0)); + + await store.savePositionAsync('p1', 1); + expect(store.length, equals(1)); + + await store.savePositionAsync('p2', 2); + expect(store.length, equals(2)); + + // Overwrite doesn't increase length + await store.savePositionAsync('p1', 10); + expect(store.length, equals(2)); + }); + + test('clear removes all positions', () async { + await store.savePositionAsync('p1', 1); + await store.savePositionAsync('p2', 2); + + store.clear(); + + expect(store.length, equals(0)); + expect(await store.loadPositionAsync('p1'), isNull); + expect(await store.loadPositionAsync('p2'), isNull); + }); + + test('remove removes specific projection position', () async { + await store.savePositionAsync('p1', 1); + await store.savePositionAsync('p2', 2); + + store.remove('p1'); + + expect(await store.loadPositionAsync('p1'), isNull); + expect(await store.loadPositionAsync('p2'), equals(2)); + expect(store.length, equals(1)); + }); + + test('handles position value of zero', () async { + await store.savePositionAsync('projection-1', 0); + + final position = await store.loadPositionAsync('projection-1'); + + expect(position, equals(0)); + }); + + test('handles large position values', () async { + const largePosition = 9223372036854775807; // Max int64 + await store.savePositionAsync('projection-1', largePosition); + + final position = await store.loadPositionAsync('projection-1'); + + expect(position, equals(largePosition)); + }); + }); +} diff --git a/packages/continuum/test/projections/projection_processor_test.dart b/packages/continuum/test/projections/projection_processor_test.dart new file mode 100644 index 0000000..7eff35e --- /dev/null +++ b/packages/continuum/test/projections/projection_processor_test.dart @@ -0,0 +1,224 @@ +import 'package:continuum/continuum.dart'; +import 'package:test/test.dart'; +import 'package:zooper_flutter_core/zooper_flutter_core.dart'; + +void main() { + group('PollingProjectionProcessor', () { + late ProjectionRegistry registry; + late InMemoryProjectionPositionStore positionStore; + late InMemoryReadModelStore<_CounterReadModel, StreamId> readModelStore; + late AsyncProjectionExecutor executor; + late List eventStore; + late PollingProjectionProcessor processor; + + setUp(() { + registry = ProjectionRegistry(); + positionStore = InMemoryProjectionPositionStore(); + readModelStore = InMemoryReadModelStore<_CounterReadModel, StreamId>(); + eventStore = []; + + final projection = _CounterProjection(); + registry.registerAsync(projection, readModelStore); + + executor = AsyncProjectionExecutor( + registry: registry, + positionStore: positionStore, + ); + }); + + /// Creates a processor with the current test fixtures. + PollingProjectionProcessor createProcessor({ + int batchSize = 100, + Duration pollingInterval = const Duration(milliseconds: 50), + }) { + return PollingProjectionProcessor( + executor: executor, + positionStore: positionStore, + eventLoader: (fromPosition, limit) async { + // Simulate loading events from position. + return eventStore.where((e) => (e.globalSequence ?? 0) >= fromPosition).take(limit).toList(); + }, + batchSize: batchSize, + pollingInterval: pollingInterval, + ); + } + + test('processBatchAsync processes events from event loader', () async { + eventStore = [ + _createEvent('s1', globalSequence: 0), + _createEvent('s1', globalSequence: 1), + _createEvent('s2', globalSequence: 2), + ]; + processor = createProcessor(); + + final result = await processor.processBatchAsync(); + + expect(result.processed, equals(3)); + expect(readModelStore.length, equals(2)); // s1 and s2 + }); + + test('processBatchAsync updates processor position', () async { + eventStore = [ + _createEvent('s1', globalSequence: 10), + _createEvent('s1', globalSequence: 11), + ]; + processor = createProcessor(); + + await processor.processBatchAsync(); + + final position = await positionStore.loadPositionAsync('_processor_position'); + expect(position, equals(11)); + }); + + test('processBatchAsync resumes from last position', () async { + eventStore = [ + _createEvent('s1', globalSequence: 0), + _createEvent('s1', globalSequence: 1), + _createEvent('s1', globalSequence: 2), + _createEvent('s1', globalSequence: 3), + ]; + + // Set position to 1, so we should start from 2. + await positionStore.savePositionAsync('_processor_position', 1); + + processor = createProcessor(); + final result = await processor.processBatchAsync(); + + // Should only process events 2 and 3. + expect(result.processed, equals(2)); + }); + + test('processBatchAsync returns empty result when no events', () async { + eventStore = []; + processor = createProcessor(); + + final result = await processor.processBatchAsync(); + + expect(result.processed, equals(0)); + expect(result.failed, equals(0)); + }); + + test('processBatchAsync respects batch size', () async { + eventStore = List.generate( + 10, + (i) => _createEvent('s$i', globalSequence: i), + ); + processor = createProcessor(batchSize: 3); + + final result = await processor.processBatchAsync(); + + // Should only process first 3 events. + expect(result.processed, equals(3)); + + final position = await positionStore.loadPositionAsync('_processor_position'); + expect(position, equals(2)); // Last processed was index 2. + }); + + test('startAsync and stopAsync control processor lifecycle', () async { + eventStore = []; + processor = createProcessor(pollingInterval: const Duration(milliseconds: 10)); + + expect(processor.isRunning, isFalse); + + await processor.startAsync(); + expect(processor.isRunning, isTrue); + + await processor.stopAsync(); + expect(processor.isRunning, isFalse); + }); + + test('startAsync processes events immediately', () async { + eventStore = [ + _createEvent('s1', globalSequence: 0), + ]; + processor = createProcessor(); + + await processor.startAsync(); + await processor.stopAsync(); + + expect(readModelStore.length, equals(1)); + }); + + test('startAsync is idempotent', () async { + eventStore = []; + processor = createProcessor(); + + await processor.startAsync(); + await processor.startAsync(); // Should not throw or duplicate. + + expect(processor.isRunning, isTrue); + + await processor.stopAsync(); + }); + + test('stopAsync waits for in-progress processing', () async { + // Create a slow event loader. + processor = PollingProjectionProcessor( + executor: executor, + positionStore: positionStore, + eventLoader: (fromPosition, limit) async { + await Future.delayed(const Duration(milliseconds: 50)); + return []; + }, + batchSize: 100, + pollingInterval: const Duration(milliseconds: 100), + ); + + await processor.startAsync(); + + // Start stop while processing is happening. + final stopFuture = processor.stopAsync(); + + await stopFuture; + + expect(processor.isRunning, isFalse); + }); + }); +} + +// --- Test Fixtures --- + +int _eventCounter = 0; + +StoredEvent _createEvent(String streamId, {required int globalSequence}) { + return StoredEvent( + eventId: EventId('evt-${_eventCounter++}'), + streamId: StreamId(streamId), + version: 0, + eventType: 'test.event', + data: const {}, + occurredOn: DateTime.now(), + metadata: const {}, + globalSequence: globalSequence, + ); +} + +class _CounterReadModel { + final String streamId; + final int count; + + _CounterReadModel({required this.streamId, required this.count}); +} + +class _CounterProjection extends SingleStreamProjection<_CounterReadModel> { + @override + Set get handledEventTypes => {_TestEvent}; + + @override + String get projectionName => 'counter'; + + @override + _CounterReadModel createInitial(StreamId streamId) { + return _CounterReadModel(streamId: streamId.value, count: 0); + } + + @override + _CounterReadModel apply(_CounterReadModel current, StoredEvent event) { + return _CounterReadModel( + streamId: current.streamId, + count: current.count + 1, + ); + } +} + +class _TestEvent {} diff --git a/packages/continuum/test/projections/projection_registry_test.dart b/packages/continuum/test/projections/projection_registry_test.dart new file mode 100644 index 0000000..a2e13b3 --- /dev/null +++ b/packages/continuum/test/projections/projection_registry_test.dart @@ -0,0 +1,211 @@ +import 'package:continuum/continuum.dart'; +import 'package:test/test.dart'; + +void main() { + group('ProjectionRegistry', () { + late ProjectionRegistry registry; + + setUp(() { + registry = ProjectionRegistry(); + }); + + test('registerInline adds projection with inline lifecycle', () { + final projection = _CounterProjection('counter-1'); + final store = InMemoryReadModelStore(); + + registry.registerInline(projection, store); + + expect(registry.length, equals(1)); + expect(registry.hasInlineProjections, isTrue); + expect(registry.hasAsyncProjections, isFalse); + }); + + test('registerAsync adds projection with async lifecycle', () { + final projection = _CounterProjection('counter-2'); + final store = InMemoryReadModelStore(); + + registry.registerAsync(projection, store); + + expect(registry.length, equals(1)); + expect(registry.hasInlineProjections, isFalse); + expect(registry.hasAsyncProjections, isTrue); + }); + + test('registerInline throws on duplicate projection name', () { + final projection1 = _CounterProjection('same-name'); + final projection2 = _CounterProjection('same-name'); + final store = InMemoryReadModelStore(); + + registry.registerInline(projection1, store); + + expect( + () => registry.registerInline(projection2, store), + throwsStateError, + ); + }); + + test('registerAsync throws on duplicate projection name', () { + final projection1 = _CounterProjection('same-name'); + final projection2 = _CounterProjection('same-name'); + final store = InMemoryReadModelStore(); + + registry.registerAsync(projection1, store); + + expect( + () => registry.registerAsync(projection2, store), + throwsStateError, + ); + }); + + test('throws on duplicate name across inline and async', () { + final projection1 = _CounterProjection('shared-name'); + final projection2 = _CounterProjection('shared-name'); + final store = InMemoryReadModelStore(); + + registry.registerInline(projection1, store); + + expect( + () => registry.registerAsync(projection2, store), + throwsStateError, + ); + }); + + test('getInlineProjectionsForEventType returns matching inline projections', () { + final projection1 = _CounterProjection('p1', {_EventA}); + final projection2 = _CounterProjection('p2', {_EventA, _EventB}); + final projection3 = _CounterProjection('p3', {_EventB}); + final store = InMemoryReadModelStore(); + + registry.registerInline(projection1, store); + registry.registerInline(projection2, store); + registry.registerAsync(projection3, store); + + final matchingA = registry.getInlineProjectionsForEventType(_EventA); + final matchingB = registry.getInlineProjectionsForEventType(_EventB); + final matchingC = registry.getInlineProjectionsForEventType(_EventC); + + // EventA: p1 (inline) and p2 (inline) + expect(matchingA.length, equals(2)); + expect( + matchingA.map((r) => r.projectionName).toSet(), + equals({'p1', 'p2'}), + ); + + // EventB: only p2 (inline), p3 is async + expect(matchingB.length, equals(1)); + expect(matchingB.first.projectionName, equals('p2')); + + // EventC: no matches + expect(matchingC, isEmpty); + }); + + test('getAsyncProjectionsForEventType returns matching async projections', () { + final projection1 = _CounterProjection('p1', {_EventA}); + final projection2 = _CounterProjection('p2', {_EventA, _EventB}); + final store = InMemoryReadModelStore(); + + registry.registerInline(projection1, store); + registry.registerAsync(projection2, store); + + final matchingA = registry.getAsyncProjectionsForEventType(_EventA); + final matchingB = registry.getAsyncProjectionsForEventType(_EventB); + + // EventA: only p2 (async), p1 is inline + expect(matchingA.length, equals(1)); + expect(matchingA.first.projectionName, equals('p2')); + + // EventB: p2 (async) + expect(matchingB.length, equals(1)); + expect(matchingB.first.projectionName, equals('p2')); + }); + + test('inlineProjections returns all inline registrations', () { + final p1 = _CounterProjection('inline-1'); + final p2 = _CounterProjection('inline-2'); + final p3 = _CounterProjection('async-1'); + final store = InMemoryReadModelStore(); + + registry.registerInline(p1, store); + registry.registerInline(p2, store); + registry.registerAsync(p3, store); + + final inline = registry.inlineProjections; + + expect(inline.length, equals(2)); + expect( + inline.map((r) => r.projectionName).toSet(), + equals({'inline-1', 'inline-2'}), + ); + }); + + test('asyncProjections returns all async registrations', () { + final p1 = _CounterProjection('inline-1'); + final p2 = _CounterProjection('async-1'); + final p3 = _CounterProjection('async-2'); + final store = InMemoryReadModelStore(); + + registry.registerInline(p1, store); + registry.registerAsync(p2, store); + registry.registerAsync(p3, store); + + final async = registry.asyncProjections; + + expect(async.length, equals(2)); + expect( + async.map((r) => r.projectionName).toSet(), + equals({'async-1', 'async-2'}), + ); + }); + + test('isEmpty returns true when no projections registered', () { + expect(registry.isEmpty, isTrue); + expect(registry.isNotEmpty, isFalse); + }); + + test('isNotEmpty returns true when projections registered', () { + final projection = _CounterProjection('p1'); + final store = InMemoryReadModelStore(); + + registry.registerInline(projection, store); + + expect(registry.isEmpty, isFalse); + expect(registry.isNotEmpty, isTrue); + }); + }); + + group('ProjectionLifecycle', () { + test('has inline and async values', () { + expect(ProjectionLifecycle.values, contains(ProjectionLifecycle.inline)); + expect(ProjectionLifecycle.values, contains(ProjectionLifecycle.async)); + expect(ProjectionLifecycle.values.length, equals(2)); + }); + }); +} + +// --- Test Fixtures --- + +class _EventA {} + +class _EventB {} + +class _EventC {} + +/// Simple projection for testing registry behavior. +class _CounterProjection extends SingleStreamProjection { + final String _name; + final Set _handledTypes; + + _CounterProjection(this._name, [Set? handledTypes]) : _handledTypes = handledTypes ?? {_EventA}; + + @override + Set get handledEventTypes => _handledTypes; + + @override + String get projectionName => _name; + + @override + int createInitial(StreamId streamId) => 0; + + @override + int apply(int current, StoredEvent event) => current + 1; +} diff --git a/packages/continuum/test/projections/projection_test.dart b/packages/continuum/test/projections/projection_test.dart new file mode 100644 index 0000000..fa2767a --- /dev/null +++ b/packages/continuum/test/projections/projection_test.dart @@ -0,0 +1,218 @@ +import 'package:continuum/continuum.dart'; +import 'package:test/test.dart'; +import 'package:zooper_flutter_core/zooper_flutter_core.dart'; + +void main() { + group('Projection', () { + test('handles() returns true for registered event types', () { + final projection = _TestSingleStreamProjection(); + + expect(projection.handles(_TestEventA), isTrue); + expect(projection.handles(_TestEventB), isTrue); + }); + + test('handles() returns false for unregistered event types', () { + final projection = _TestSingleStreamProjection(); + + expect(projection.handles(_TestEventC), isFalse); + expect(projection.handles(String), isFalse); + }); + }); + + group('SingleStreamProjection', () { + test('extractKey returns the event stream ID', () { + final projection = _TestSingleStreamProjection(); + final event = _createStoredEvent( + streamId: const StreamId('stream-123'), + eventType: 'test.event_a', + ); + + final key = projection.extractKey(event); + + expect(key, equals(const StreamId('stream-123'))); + }); + + test('createInitial creates read model for stream ID', () { + final projection = _TestSingleStreamProjection(); + + final readModel = projection.createInitial(const StreamId('stream-456')); + + expect(readModel.streamId, equals('stream-456')); + expect(readModel.eventCount, equals(0)); + }); + + test('apply updates read model with event', () { + final projection = _TestSingleStreamProjection(); + final initial = _TestReadModel(streamId: 'stream-1', eventCount: 0); + final event = _createStoredEvent( + streamId: const StreamId('stream-1'), + eventType: 'test.event_a', + ); + + final updated = projection.apply(initial, event); + + expect(updated.eventCount, equals(1)); + }); + + test('handledEventTypes returns declared types', () { + final projection = _TestSingleStreamProjection(); + + expect(projection.handledEventTypes, contains(_TestEventA)); + expect(projection.handledEventTypes, contains(_TestEventB)); + expect(projection.handledEventTypes.length, equals(2)); + }); + + test('projectionName returns unique identifier', () { + final projection = _TestSingleStreamProjection(); + + expect(projection.projectionName, equals('test-single-stream')); + }); + }); + + group('MultiStreamProjection', () { + test('extractKey returns key from event data', () { + final projection = _TestMultiStreamProjection(); + final event = _createStoredEvent( + streamId: const StreamId('stream-1'), + eventType: 'test.event_a', + data: {'categoryId': 'category-abc'}, + ); + + final key = projection.extractKey(event); + + expect(key, equals('category-abc')); + }); + + test('createInitial creates read model for key', () { + final projection = _TestMultiStreamProjection(); + + final readModel = projection.createInitial('category-xyz'); + + expect(readModel.categoryId, equals('category-xyz')); + expect(readModel.totalEvents, equals(0)); + }); + + test('apply updates read model with event from any stream', () { + final projection = _TestMultiStreamProjection(); + final initial = _CategoryStats(categoryId: 'cat-1', totalEvents: 5); + final event = _createStoredEvent( + streamId: const StreamId('different-stream'), + eventType: 'test.event_a', + data: {'categoryId': 'cat-1'}, + ); + + final updated = projection.apply(initial, event); + + expect(updated.totalEvents, equals(6)); + }); + + test('handledEventTypes returns declared types', () { + final projection = _TestMultiStreamProjection(); + + expect(projection.handledEventTypes, contains(_TestEventA)); + expect(projection.handledEventTypes.length, equals(1)); + }); + + test('projectionName returns unique identifier', () { + final projection = _TestMultiStreamProjection(); + + expect(projection.projectionName, equals('test-multi-stream')); + }); + }); +} + +// --- Test Fixtures --- + +/// Marker class for test event type A. +class _TestEventA {} + +/// Marker class for test event type B. +class _TestEventB {} + +/// Marker class for test event type C (not handled). +class _TestEventC {} + +/// Simple read model for single-stream projection tests. +class _TestReadModel { + final String streamId; + final int eventCount; + + _TestReadModel({required this.streamId, required this.eventCount}); +} + +/// Simple read model for multi-stream projection tests. +class _CategoryStats { + final String categoryId; + final int totalEvents; + + _CategoryStats({required this.categoryId, required this.totalEvents}); +} + +/// Test implementation of SingleStreamProjection. +class _TestSingleStreamProjection extends SingleStreamProjection<_TestReadModel> { + @override + Set get handledEventTypes => {_TestEventA, _TestEventB}; + + @override + String get projectionName => 'test-single-stream'; + + @override + _TestReadModel createInitial(StreamId streamId) { + return _TestReadModel(streamId: streamId.value, eventCount: 0); + } + + @override + _TestReadModel apply(_TestReadModel current, StoredEvent event) { + return _TestReadModel( + streamId: current.streamId, + eventCount: current.eventCount + 1, + ); + } +} + +/// Test implementation of MultiStreamProjection. +class _TestMultiStreamProjection extends MultiStreamProjection<_CategoryStats, String> { + @override + Set get handledEventTypes => {_TestEventA}; + + @override + String get projectionName => 'test-multi-stream'; + + @override + String extractKey(StoredEvent event) { + return event.data['categoryId'] as String; + } + + @override + _CategoryStats createInitial(String key) { + return _CategoryStats(categoryId: key, totalEvents: 0); + } + + @override + _CategoryStats apply(_CategoryStats current, StoredEvent event) { + return _CategoryStats( + categoryId: current.categoryId, + totalEvents: current.totalEvents + 1, + ); + } +} + +/// Counter for generating unique event IDs in tests. +int _eventIdCounter = 0; + +/// Helper to create a stored event for testing. +StoredEvent _createStoredEvent({ + required StreamId streamId, + required String eventType, + Map data = const {}, +}) { + return StoredEvent( + eventId: EventId('test-event-${_eventIdCounter++}'), + streamId: streamId, + version: 0, + eventType: eventType, + data: data, + occurredOn: DateTime.now(), + metadata: const {}, + ); +} diff --git a/packages/continuum/test/projections/read_model_store_test.dart b/packages/continuum/test/projections/read_model_store_test.dart new file mode 100644 index 0000000..6f5e240 --- /dev/null +++ b/packages/continuum/test/projections/read_model_store_test.dart @@ -0,0 +1,132 @@ +import 'package:continuum/src/projections/read_model_store.dart'; +import 'package:test/test.dart'; + +void main() { + group('InMemoryReadModelStore', () { + late InMemoryReadModelStore<_TestReadModel, String> store; + + setUp(() { + store = InMemoryReadModelStore<_TestReadModel, String>(); + }); + + test('loadAsync returns null for missing key', () async { + final result = await store.loadAsync('non-existent'); + + expect(result, isNull); + }); + + test('saveAsync stores read model', () async { + final model = _TestReadModel(id: 'test-1', value: 42); + + await store.saveAsync('test-1', model); + final loaded = await store.loadAsync('test-1'); + + expect(loaded, isNotNull); + expect(loaded!.id, equals('test-1')); + expect(loaded.value, equals(42)); + }); + + test('saveAsync overwrites existing read model', () async { + final model1 = _TestReadModel(id: 'test-1', value: 10); + final model2 = _TestReadModel(id: 'test-1', value: 20); + + await store.saveAsync('test-1', model1); + await store.saveAsync('test-1', model2); + final loaded = await store.loadAsync('test-1'); + + expect(loaded!.value, equals(20)); + }); + + test('deleteAsync removes read model', () async { + final model = _TestReadModel(id: 'test-1', value: 42); + await store.saveAsync('test-1', model); + + await store.deleteAsync('test-1'); + final loaded = await store.loadAsync('test-1'); + + expect(loaded, isNull); + }); + + test('deleteAsync is no-op for missing key', () async { + // Should not throw + await store.deleteAsync('non-existent'); + + expect(store.length, equals(0)); + }); + + test('length returns number of stored models', () async { + expect(store.length, equals(0)); + + await store.saveAsync('a', _TestReadModel(id: 'a', value: 1)); + expect(store.length, equals(1)); + + await store.saveAsync('b', _TestReadModel(id: 'b', value: 2)); + expect(store.length, equals(2)); + + await store.deleteAsync('a'); + expect(store.length, equals(1)); + }); + + test('clear removes all stored models', () async { + await store.saveAsync('a', _TestReadModel(id: 'a', value: 1)); + await store.saveAsync('b', _TestReadModel(id: 'b', value: 2)); + + store.clear(); + + expect(store.length, equals(0)); + expect(await store.loadAsync('a'), isNull); + expect(await store.loadAsync('b'), isNull); + }); + + test('stores different keys independently', () async { + final modelA = _TestReadModel(id: 'a', value: 100); + final modelB = _TestReadModel(id: 'b', value: 200); + + await store.saveAsync('a', modelA); + await store.saveAsync('b', modelB); + + final loadedA = await store.loadAsync('a'); + final loadedB = await store.loadAsync('b'); + + expect(loadedA!.value, equals(100)); + expect(loadedB!.value, equals(200)); + }); + }); + + group('InMemoryReadModelStore with complex keys', () { + test('works with StreamId-like value objects', () async { + final store = InMemoryReadModelStore(); + final key1 = _StreamIdLike('stream-1'); + final key2 = _StreamIdLike('stream-2'); + + await store.saveAsync(key1, 42); + await store.saveAsync(key2, 99); + + expect(await store.loadAsync(key1), equals(42)); + expect(await store.loadAsync(key2), equals(99)); + }); + }); +} + +// --- Test Fixtures --- + +/// Simple read model for testing. +class _TestReadModel { + final String id; + final int value; + + _TestReadModel({required this.id, required this.value}); +} + +/// Value object to test as map key (must implement == and hashCode). +class _StreamIdLike { + final String value; + + _StreamIdLike(this.value); + + @override + bool operator ==(Object other) => identical(this, other) || other is _StreamIdLike && runtimeType == other.runtimeType && value == other.value; + + @override + int get hashCode => value.hashCode; +} diff --git a/packages/continuum_store_hive/lib/src/hive_event_store.dart b/packages/continuum_store_hive/lib/src/hive_event_store.dart index 45c2a99..72617a5 100644 --- a/packages/continuum_store_hive/lib/src/hive_event_store.dart +++ b/packages/continuum_store_hive/lib/src/hive_event_store.dart @@ -12,7 +12,7 @@ import 'package:zooper_flutter_core/zooper_flutter_core.dart'; /// /// The store uses a composite key structure to efficiently query events /// by stream ID while maintaining per-stream ordering. -final class HiveEventStore implements AtomicEventStore { +final class HiveEventStore implements AtomicEventStore, ProjectionEventStore { /// Box suffix used for transaction-log metadata. static const String _transactionsBoxSuffix = '_transactions'; @@ -447,4 +447,42 @@ final class HiveEventStore implements AtomicEventStore { globalSequence: map['globalSequence'] as int?, ); } + + @override + Future> loadEventsFromPositionAsync( + int fromGlobalSequence, + int limit, + ) async { + return _runExclusiveAsync>(() async { + // Collect all events from the box. + final List allEvents = []; + + for (final String json in _eventsBox.values) { + final event = _deserializeEvent(json); + if (event.globalSequence != null && event.globalSequence! >= fromGlobalSequence) { + allEvents.add(event); + } + } + + // Sort by global sequence. + allEvents.sort( + (a, b) => (a.globalSequence ?? 0).compareTo(b.globalSequence ?? 0), + ); + + // Return up to limit events. + return allEvents.take(limit).toList(); + }); + } + + @override + Future getMaxGlobalSequenceAsync() async { + return _runExclusiveAsync(() async { + // The _globalSequence counter is one ahead of the max, so subtract 1. + // If _globalSequence is 0, no events have been stored. + if (_globalSequence == 0) { + return null; + } + return _globalSequence - 1; + }); + } } diff --git a/packages/continuum_store_memory/lib/src/in_memory_event_store.dart b/packages/continuum_store_memory/lib/src/in_memory_event_store.dart index f7e47a1..0515552 100644 --- a/packages/continuum_store_memory/lib/src/in_memory_event_store.dart +++ b/packages/continuum_store_memory/lib/src/in_memory_event_store.dart @@ -7,7 +7,7 @@ import 'package:continuum/continuum.dart'; /// /// Thread-safety: This implementation is not thread-safe. For concurrent /// access, external synchronization is required. -final class InMemoryEventStore implements AtomicEventStore { +final class InMemoryEventStore implements AtomicEventStore, ProjectionEventStore { /// Internal storage of events by stream ID. final Map> _streams = {}; @@ -124,4 +124,38 @@ final class InMemoryEventStore implements AtomicEventStore { /// Returns the total number of events across all streams. int get eventCount => _streams.values.fold(0, (sum, events) => sum + events.length); + + @override + Future> loadEventsFromPositionAsync( + int fromGlobalSequence, + int limit, + ) async { + // Collect all events with globalSequence >= fromGlobalSequence. + final List allEvents = _streams.values.expand((events) => events).where((event) => (event.globalSequence ?? 0) >= fromGlobalSequence).toList(); + + // Sort by global sequence. + allEvents.sort((a, b) => (a.globalSequence ?? 0).compareTo(b.globalSequence ?? 0)); + + // Return up to limit events. + return allEvents.take(limit).toList(); + } + + @override + Future getMaxGlobalSequenceAsync() async { + if (_streams.isEmpty) { + return null; + } + + int? maxSequence; + for (final events in _streams.values) { + for (final event in events) { + if (event.globalSequence != null) { + if (maxSequence == null || event.globalSequence! > maxSequence) { + maxSequence = event.globalSequence; + } + } + } + } + return maxSequence; + } } diff --git a/packages/continuum_store_memory/test/in_memory_event_store_test.dart b/packages/continuum_store_memory/test/in_memory_event_store_test.dart index 528ddd7..6e8cde6 100644 --- a/packages/continuum_store_memory/test/in_memory_event_store_test.dart +++ b/packages/continuum_store_memory/test/in_memory_event_store_test.dart @@ -239,6 +239,88 @@ void main() { expect(store.eventCount, equals(2)); }); }); + + group('loadEventsFromPositionAsync', () { + test('should return empty list when no events exist', () async { + final events = await store.loadEventsFromPositionAsync(0, 100); + expect(events, isEmpty); + }); + + test('should return events from position', () async { + final s1 = const StreamId('stream-1'); + final s2 = const StreamId('stream-2'); + + await store.appendEventsAsync(s1, ExpectedVersion.noStream, [ + _createStoredEvent(s1, 0, 'e1'), + ]); + await store.appendEventsAsync(s2, ExpectedVersion.noStream, [ + _createStoredEvent(s2, 0, 'e2'), + ]); + + // Load from position 1 (skip first event). + final events = await store.loadEventsFromPositionAsync(1, 100); + + expect(events.length, equals(1)); + expect(events.first.globalSequence, equals(1)); + }); + + test('should respect limit parameter', () async { + final s1 = const StreamId('stream-1'); + await store.appendEventsAsync(s1, ExpectedVersion.noStream, [ + _createStoredEvent(s1, 0, 'e1'), + _createStoredEvent(s1, 1, 'e2'), + _createStoredEvent(s1, 2, 'e3'), + ]); + + final events = await store.loadEventsFromPositionAsync(0, 2); + + expect(events.length, equals(2)); + expect(events[0].globalSequence, equals(0)); + expect(events[1].globalSequence, equals(1)); + }); + + test('should order events by global sequence', () async { + final s1 = const StreamId('stream-1'); + final s2 = const StreamId('stream-2'); + + await store.appendEventsAsync(s1, ExpectedVersion.noStream, [ + _createStoredEvent(s1, 0, 'e1'), + ]); + await store.appendEventsAsync(s2, ExpectedVersion.noStream, [ + _createStoredEvent(s2, 0, 'e2'), + ]); + await store.appendEventsAsync(s1, ExpectedVersion.exact(0), [ + _createStoredEvent(s1, 1, 'e3'), + ]); + + final events = await store.loadEventsFromPositionAsync(0, 100); + + expect(events.length, equals(3)); + expect(events[0].globalSequence, equals(0)); + expect(events[1].globalSequence, equals(1)); + expect(events[2].globalSequence, equals(2)); + }); + }); + + group('getMaxGlobalSequenceAsync', () { + test('should return null when no events', () async { + final maxSeq = await store.getMaxGlobalSequenceAsync(); + expect(maxSeq, isNull); + }); + + test('should return max global sequence', () async { + final s1 = const StreamId('stream-1'); + await store.appendEventsAsync(s1, ExpectedVersion.noStream, [ + _createStoredEvent(s1, 0, 'e1'), + _createStoredEvent(s1, 1, 'e2'), + _createStoredEvent(s1, 2, 'e3'), + ]); + + final maxSeq = await store.getMaxGlobalSequenceAsync(); + + expect(maxSeq, equals(2)); + }); + }); }); } From b861af950b0cb55ee5e763b514b8851bf4b72f02 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 19 Jan 2026 10:11:50 +0800 Subject: [PATCH 3/9] Specs for code gen --- .../refactor-projections-to-codegen/design.md | 499 ++++++++++++++++++ .../proposal.md | 135 +++++ .../specs/continuum-projections/spec.md | 327 ++++++++++++ .../refactor-projections-to-codegen/tasks.md | 142 +++++ 4 files changed, 1103 insertions(+) create mode 100644 openspec/changes/refactor-projections-to-codegen/design.md create mode 100644 openspec/changes/refactor-projections-to-codegen/proposal.md create mode 100644 openspec/changes/refactor-projections-to-codegen/specs/continuum-projections/spec.md create mode 100644 openspec/changes/refactor-projections-to-codegen/tasks.md diff --git a/openspec/changes/refactor-projections-to-codegen/design.md b/openspec/changes/refactor-projections-to-codegen/design.md new file mode 100644 index 0000000..495757e --- /dev/null +++ b/openspec/changes/refactor-projections-to-codegen/design.md @@ -0,0 +1,499 @@ +# Design: Code-Generated Projection System + +## Context + +Continuum uses code generation extensively for aggregates: +- `@Aggregate()` annotation marks aggregate classes +- `@AggregateEvent(of: ...)` links events to aggregates +- Generator produces mixins, dispatchers, and registries +- Lints catch missing handlers at edit-time + +The projection system was implemented without following this pattern, forcing users to write boilerplate that should be generated. This design document specifies how to bring projections in line with the established code generation approach. + +**Stakeholders**: Continuum library users building read models from events. + +**Constraints**: +- Must follow existing generator architecture patterns +- Must support both single-stream and multi-stream projections +- Must integrate with existing lint infrastructure +- Must not break unrelated aggregate code generation +- Must keep runtime dependencies minimal + +## Goals / Non-Goals + +### Goals +- Eliminate manual `handledEventTypes` declaration +- Eliminate manual event dispatch switch statements +- Generate `apply` contract enforcement via mixins +- Provide lint support for missing projection handlers +- Auto-discover projections for registry configuration +- Maintain consistency with aggregate code generation patterns + +### Non-Goals +- Auto-generate `extractKey()` for multi-stream projections (requires domain knowledge) +- Auto-generate `createInitial()` (requires domain knowledge) +- Auto-generate read model classes themselves +- Support projection versioning or schema evolution (future work) +- Real-time event streaming infrastructure + +## Decisions + +### Decision 1: Annotation Design + +#### `@Projection()` Annotation + +```dart +/// Marks a class as a projection that transforms events into read models. +class Projection { + /// Unique name for this projection (used in position tracking). + final String name; + + /// The list of event types this projection handles. + /// + /// The generator uses this list to create the required `apply` + /// methods in the generated mixin. + final List events; + + const Projection({required this.name, required this.events}); +} +``` + +The `name` is required (not derived from class name) because: +- Projection names are persisted for position tracking +- Renaming a class should not break position recovery +- Explicit naming prevents accidental collisions + +The `events` list is required because: +- It is the source of truth for which events the projection handles +- Generator uses it to create the mixin with required `apply` methods +- Compiler then enforces that the user implements all handlers + +#### Event Declaration Strategy + +**Decision**: Projections declare their handled events in the `@Projection` annotation's `events` parameter. + +Rationale: +- One place to see all events a projection handles +- No need to modify event classes when adding projections +- Events may be handled by multiple projections—annotation on events would be verbose +- Consistent with "projection owns its event dependencies" principle +- Generator creates the mixin first, user implements handlers second (same flow as aggregates) +- Compiler enforces all handlers are implemented + +```dart +@Projection(name: 'user-profile', events: [UserRegistered, EmailChanged, NameChanged]) +class UserProfileProjection extends SingleStreamProjection + with _$UserProfileProjectionHandlers { + // Generator creates mixin requiring applyUserRegistered, applyEmailChanged, applyNameChanged + // User implements these methods; Dart compiler enforces completion +} +``` + +The generator reads the `events` list and generates the `_$Handlers` mixin with required `apply` methods. + +### Decision 2: Generated Mixin Structure + +For a projection class `UserProfileProjection` with handlers for `UserRegistered`, `EmailChanged`, and `NameChanged`: + +```dart +// Generated in user_profile_projection.g.dart + +/// Generated mixin requiring initialization and apply methods for UserProfileProjection. +mixin _$UserProfileProjectionHandlers { + /// Creates the initial read model state for a new key. + /// + /// Called when processing the first event for a given key. + UserProfile createInitial(StreamId streamId); + + /// Applies a UserRegistered event to the read model. + UserProfile applyUserRegistered(UserProfile current, UserRegistered event); + + /// Applies a EmailChanged event to the read model. + UserProfile applyEmailChanged(UserProfile current, EmailChanged event); + + /// Applies a NameChanged event to the read model. + UserProfile applyNameChanged(UserProfile current, NameChanged event); +} +``` + +The mixin declares: +- `createInitial()` to initialize the read model when first event for a key arrives +- Abstract `apply` methods for each event in the `events` list + +The Dart compiler enforces that the projection class implements all methods. + +### Decision 3: Generated Dispatcher + +```dart +/// Generated extension providing event dispatch for UserProfileProjection. +extension $UserProfileProjectionEventDispatch on UserProfileProjection { + /// Set of event types this projection handles. + static const Set handledEventTypes = { + UserRegistered, + EmailChanged, + NameChanged, + }; + + /// Routes an event to the appropriate apply method. + UserProfile applyEvent(UserProfile current, ContinuumEvent event) { + return switch (event) { + UserRegistered() => applyUserRegistered(current, event), + EmailChanged() => applyEmailChanged(current, event), + NameChanged() => applyNameChanged(current, event), + _ => throw UnsupportedEventException( + eventType: event.runtimeType, + projectionType: UserProfileProjection, + ), + }; + } +} +``` + +The generated extension: +- Provides `handledEventTypes` as a static constant (no runtime overhead) +- Provides `applyEvent()` dispatcher routing to typed handlers +- Throws on unhandled events (fail-fast, no silent drops) + +### Decision 4: Base Class Simplification + +Current base classes require manual overrides. Simplified versions delegate to generated code: + +```dart +/// Base class for single-stream projections. +abstract class SingleStreamProjection + extends Projection { + + /// Extracts the stream ID from the event (always the event's stream). + @override + StreamId extractKey(StoredEvent event) => event.streamId; + + /// Creates initial read model state for a new stream. + TReadModel createInitial(StreamId streamId); + + // Note: apply() is now provided by generated extension, not overridden here +} +``` + +The `handledEventTypes` getter and `apply()` method move to generated code. + +### Decision 5: Projection Discovery + +The generator scans for classes annotated with `@Projection()` and reads the `events` list from the annotation: + +```dart +final class ProjectionDiscovery { + List discoverProjections(LibraryElement library) { + final projections = []; + + for (final element in library.classes) { + if (!_projectionChecker.hasAnnotationOf(element)) continue; + + final annotation = _projectionChecker.firstAnnotationOf(element); + final name = annotation?.getField('name')?.toStringValue(); + + // Read events list from annotation + final eventsField = annotation?.getField('events'); + final eventTypes = []; + if (eventsField != null && !eventsField.isNull) { + final eventsList = eventsField.toListValue(); + for (final eventValue in eventsList ?? []) { + final eventType = eventValue.toTypeValue(); + if (eventType != null) eventTypes.add(eventType); + } + } + + projections.add(ProjectionInfo( + element: element, + name: name, + eventTypes: eventTypes, + )); + } + + return projections; + } +} +``` + +### Decision 6: Generated Projection Bundle + +Similar to `GeneratedAggregate`, we generate a `GeneratedProjection` bundle: + +```dart +/// Generated projection bundle for UserProfileProjection. +final $UserProfileProjection = GeneratedProjection( + projectionName: 'user-profile', + handledEventTypes: {UserRegistered, EmailChanged, NameChanged}, + factory: () => UserProfileProjection(), +); +``` + +And a combined list: + +```dart +/// All generated projections in this package. +final List $projectionList = [ + $UserProfileProjection, + $UserStatisticsProjection, +]; +``` + +### Decision 7: Event Type Discovery from Annotation + +The generator reads the `events` list from the `@Projection` annotation to determine handled event types: + +```dart +// User writes: +@Projection(name: 'user-profile', events: [UserRegistered, EmailChanged, NameChanged]) +class UserProfileProjection extends SingleStreamProjection + with _$UserProfileProjectionHandlers { ... } + +// Generator reads annotation and produces: +// - Event types: [UserRegistered, EmailChanged, NameChanged] +// - Mixin with: applyUserRegistered, applyEmailChanged, applyNameChanged +// - User then implements these methods +``` + +This approach: +- Annotation is the single source of truth for handled events +- Generator creates requirements first, user implements second +- Compiler enforces all handlers are implemented (mixin has abstract methods) +- Mirrors the aggregate flow: annotation → generated mixin → user implementation + +### Decision 8: StoredEvent vs ContinuumEvent in Handlers + +**Question**: Should handlers receive `StoredEvent` (with metadata) or the typed `ContinuumEvent` payload? + +**Decision**: Handlers receive the **typed event payload** (`ContinuumEvent`), not `StoredEvent`. + +Rationale: +- Consistent with aggregate `apply(event)` pattern +- Cleaner handler signatures +- Metadata access available via separate mechanism if needed +- The dispatcher unwraps `StoredEvent` before calling handler + +```dart +// Handler signature (clean, typed): +UserProfile applyEmailChanged(UserProfile current, EmailChanged event); + +// NOT this (exposes storage concerns): +UserProfile applyEmailChanged(UserProfile current, StoredEvent event); +``` + +The generated dispatcher handles the `StoredEvent` → typed event conversion. + +### Decision 9: Lint Rule Design + +New lint: `continuum_missing_projection_handlers` + +Triggers when: +- Class has `@Projection()` annotation +- Class mixes in `_$Handlers` +- Class is missing one or more required `apply` implementations + +Quick-fix generates stub methods: + +```dart +@override +UserProfile applyEmailChanged(UserProfile current, EmailChanged event) { + // TODO: Implement handler + throw UnimplementedError(); +} +``` + +### Decision 10: Multi-Stream Projection Specifics + +Multi-stream projections require custom `extractKey()` logic that cannot be generated: + +```dart +@Projection(name: 'library-statistics', events: [AudioFileAdded, AudioFileRemoved]) +class LibraryStatisticsProjection + extends MultiStreamProjection + with _$LibraryStatisticsProjectionHandlers { + + // User MUST implement this—key extraction requires domain knowledge + @override + String extractKey(StoredEvent event) { + return event.data['libraryId'] as String; + } + + @override + LibraryStats createInitial(String key) => LibraryStats(libraryId: key); + + // Generated mixin requires these; user implements them: + @override + LibraryStats applyAudioFileAdded(LibraryStats current, AudioFileAdded event) => + current.copyWith(fileCount: current.fileCount + 1); + + @override + LibraryStats applyAudioFileRemoved(LibraryStats current, AudioFileRemoved event) => + current.copyWith(fileCount: current.fileCount - 1); +} +``` + +The generator only generates: +- Handler mixin +- Event dispatcher +- `handledEventTypes` set + +It does NOT generate `extractKey()` or `createInitial()`. + +### Decision 11: Schema Change Detection and Lazy Rebuild + +When the `events` list in a `@Projection` annotation changes, stale read models must be rebuilt without blocking the app. + +#### Schema Hash + +The generator computes a `schemaHash` from the sorted event type names: + +```dart +// Generator computes: +final schemaHash = _computeHash(['EmailChanged', 'NameChanged', 'UserRegistered']); + +// Included in generated bundle: +final $UserProfileProjection = GeneratedProjection( + name: 'user-profile', + schemaHash: 'a1b2c3d4', + events: {UserRegistered, EmailChanged, NameChanged}, +); +``` + +#### Position Store with Schema Hash + +The position store saves the schema hash alongside the position: + +```dart +class ProjectionPosition { + final int lastProcessedSequence; + final String schemaHash; +} +``` + +#### Startup Flow + +On startup (for both inline and async projections): + +1. Load stored position for projection +2. Compare `stored.schemaHash` vs `generated.schemaHash` +3. If different: + - Log: `"Projection 'user-profile' schema changed, rebuilding..."` + - Mark projection as stale + - Clear read model store for this projection + - Reset position to 0 + - Start background rebuild (non-blocking) +4. If same: continue from stored position + +#### Read Model Result with Staleness Flag + +Reads return data with a staleness indicator: + +```dart +class ReadModelResult { + final T? value; + final bool isStale; + + const ReadModelResult({this.value, required this.isStale}); +} + +// Usage: +final result = await readModelStore.loadAsync(streamId); +if (result.isStale) { + // Show data but indicate it may be outdated + showWithStaleIndicator(result.value); +} else { + show(result.value); +} +``` + +#### Unified Behavior + +Both single-stream and multi-stream projections use the same logic: +- Background rebuild (non-blocking) +- Reads return available data with `isStale: true` until rebuild completes +- No special-casing by projection type + +**Rationale**: +- App starts instantly (no blocking rebuild) +- Users can display partial/stale data with appropriate UI indicators +- Consistent behavior regardless of projection type +- Simple mental model for developers + +## Alternatives Considered + +### Alternative A: Annotation on Events (`@ProjectionEvent`) + +```dart +@AggregateEvent(of: User, type: 'user.email_changed') +@ProjectionEvent(of: UserProfileProjection) +@ProjectionEvent(of: UserStatisticsProjection) +class EmailChanged implements ContinuumEvent { ... } +``` + +**Rejected because**: +- Couples events to projections (events should be ignorant of consumers) +- Verbose when event is used by multiple projections +- Requires modifying event files when adding projections +- Inconsistent with principle that projections "own" their event dependencies + +### Alternative B: Method Signature Inspection + +```dart +@Projection(name: 'user-profile') +class UserProfileProjection extends SingleStreamProjection + with _$UserProfileProjectionHandlers { + // User writes apply methods first, generator discovers them + UserProfile applyEmailChanged(UserProfile current, EmailChanged event) => ...; +} +``` + +**Rejected because**: +- User writes methods first, then runs generator—backwards from aggregate pattern +- Generator cannot enforce anything until user writes code +- User could forget to run generator after adding handlers +- Doesn't provide the "implement missing methods" IDE experience + +### Alternative C: Reflection-Based Discovery + +Discover handlers at runtime via reflection. + +**Rejected because**: +- Violates Continuum's "no reflection" principle +- Increases runtime overhead +- Breaks tree-shaking +- Dart's mirrors are limited on Flutter + +## Risks / Trade-offs + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Method naming convention too strict | Medium | Document clearly; provide lint quick-fixes | +| Breaking change for existing projections | Low | Feature is new; migration guide provided | +| Generator complexity increases | Medium | Reuse existing aggregate discovery patterns | +| StoredEvent access lost in handlers | Low | Provide alternative API for metadata access | + +## Implementation Sequence + +1. **Phase 1: Annotations** — Add `@Projection()` annotation to core package +2. **Phase 2: Discovery** — Implement projection discovery in generator +3. **Phase 3: Emission** — Generate mixin, dispatcher, and projection bundle +4. **Phase 4: Base Class Refactor** — Simplify `SingleStreamProjection` and `MultiStreamProjection` +5. **Phase 5: Combining Builder** — Generate `$projectionList` in `continuum.g.dart` +6. **Phase 6: Registry Integration** — Modify `ProjectionRegistry` to accept generated bundles +7. **Phase 7: Lints** — Add `continuum_missing_projection_handlers` rule +8. **Phase 8: Documentation & Examples** — Update README and examples + +## Open Questions + +1. ~~**Should `createInitial()` be abstract in the mixin?**~~ + - **Decided**: Yes, include in generated mixin contract + - Compiler enforces implementation + - Lint can provide quick-fix stub + - Consistent with handler pattern + +2. **How to handle projection inheritance?** + - Can a projection extend another projection? + - Recommendation: Disallow for v1; add if use case emerges + +3. **Should the read model type be inferred or declared?** + - Currently: Inferred from base class generic parameter + - Alternative: Explicit in annotation `@Projection(...)` + - Recommendation: Infer from `SingleStreamProjection` generic—less duplication diff --git a/openspec/changes/refactor-projections-to-codegen/proposal.md b/openspec/changes/refactor-projections-to-codegen/proposal.md new file mode 100644 index 0000000..89cbb88 --- /dev/null +++ b/openspec/changes/refactor-projections-to-codegen/proposal.md @@ -0,0 +1,135 @@ +# Change: Refactor Projections to Use Code Generation + +## Why + +The current projection implementation requires users to manually write boilerplate code that violates Continuum's core philosophy: **use code generation to eliminate repetitive patterns**. + +### Current Pain Points + +1. **Manual `handledEventTypes` declaration**: Users must manually maintain a `Set` that lists every event they handle—easy to forget when adding new handlers. + +2. **Manual `apply()` dispatcher**: Users write a large switch statement inside `apply()` to route events—the exact pattern the aggregate generator already automates. + +3. **No annotation-based discovery**: Events are not linked to projections via annotations, so the generator cannot assist. + +4. **Inconsistent developer experience**: Aggregates get generated mixins, dispatchers, and registries; projections get nothing. + +### Comparison + +| Aspect | Aggregates (current) | Projections (current) | +|--------|---------------------|----------------------| +| Event handlers | `apply(event)` methods | Manual switch in single `apply()` | +| Event registration | Auto-discovered via `@AggregateEvent` | Manual `handledEventTypes` set | +| Dispatch boilerplate | Generated | Hand-written | +| Lint support | `continuum_missing_apply_handlers` | None | + +The projection system should follow the same ergonomic pattern as aggregates. + +## What Changes + +### Core Annotation Changes + +- **ADDED**: `@Projection()` annotation to mark projection classes +- **ADDED**: `@ProjectionEvent()` annotation to link events to projections (similar to `@AggregateEvent`) +- **MODIFIED**: Projection base classes simplified—no longer require manual `handledEventTypes` override + +### Code Generation Changes + +- **ADDED**: Generator discovers `@Projection` classes and `@ProjectionEvent` annotations +- **ADDED**: Generator emits `_$EventHandlers` mixin (requires `apply` methods) +- **ADDED**: Generator emits `applyEvent()` dispatcher extension for projections +- **ADDED**: Generator emits `$projectionList` global list (like `$aggregateList`) +- **ADDED**: Generator emits projection registration helpers with auto-discovered event types + +### Runtime Changes + +- **MODIFIED**: `ProjectionRegistry` accepts generated projection bundles +- **REMOVED**: Manual `handledEventTypes` getter from user code (generated instead) + +### Lint Support + +- **ADDED**: `continuum_missing_projection_handlers` lint rule +- **ADDED**: Quick-fix to generate missing `apply` methods + +## Target Developer Experience + +### Before (current painful approach) + +```dart +class UserProfileProjection extends SingleStreamProjection { + @override + Set get handledEventTypes => {UserRegistered, EmailChanged, NameChanged}; + + @override + String get projectionName => 'user-profile'; + + @override + UserProfile createInitial(StreamId streamId) => + UserProfile(id: streamId.value); + + @override + UserProfile apply(UserProfile current, StoredEvent event) { + return switch (event.payload) { + UserRegistered e => current.copyWith(name: e.name, email: e.email), + EmailChanged e => current.copyWith(email: e.newEmail), + NameChanged e => current.copyWith(name: e.newName), + _ => current, + }; + } +} +``` + +### After (code-generated approach) + +```dart +part 'user_profile_projection.g.dart'; + +@Projection(name: 'user-profile', events: [UserRegistered, EmailChanged, NameChanged]) +class UserProfileProjection extends SingleStreamProjection + with _$UserProfileProjectionHandlers { + + @override + UserProfile createInitial(StreamId streamId) => + UserProfile(id: streamId.value); + + // Generated mixin requires these handlers; Dart compiler enforces implementation: + + @override + UserProfile applyUserRegistered(UserProfile current, UserRegistered event) => + current.copyWith(name: event.name, email: event.email); + + @override + UserProfile applyEmailChanged(UserProfile current, EmailChanged event) => + current.copyWith(email: event.newEmail); + + @override + UserProfile applyNameChanged(UserProfile current, NameChanged event) => + current.copyWith(name: event.newName); +} +``` + +The `events` list in the annotation tells the generator which handlers to require. The generator creates the mixin with abstract `apply` methods. The Dart compiler then enforces that the user implements all handlers. + +## Impact + +- Affected specs: `continuum-projections` (to be created or updated) +- Affected code: + - `packages/continuum/lib/src/annotations/` — new annotations + - `packages/continuum/lib/src/projections/` — simplified base classes + - `packages/continuum_generator/lib/src/` — projection discovery and emission + - `packages/continuum_lints/lib/src/` — new lint rules +- Breaking changes: **YES** — projection API changes (but feature is new, so limited impact) +- Migration: Existing manual projections need refactoring to annotation-based approach + +## Open Questions + +1. **Read model type inference**: Should the read model type be inferred from the base class generic parameter, or explicitly declared in the annotation? + - **Recommendation**: Infer from `SingleStreamProjection` generic—less duplication + +2. **Multi-stream key extraction**: How should multi-stream projections declare their key extraction logic? This cannot be generated. + - **Recommendation**: Keep `extractKey()` as user-implemented; only generate dispatch + +3. **Projection bundle structure**: What should `$UserProfileProjection` contain? + - Event type set (for registry) + - Factory for creating projection instances? + - **Recommendation**: Similar to `GeneratedAggregate` but simpler diff --git a/openspec/changes/refactor-projections-to-codegen/specs/continuum-projections/spec.md b/openspec/changes/refactor-projections-to-codegen/specs/continuum-projections/spec.md new file mode 100644 index 0000000..8d3e2c0 --- /dev/null +++ b/openspec/changes/refactor-projections-to-codegen/specs/continuum-projections/spec.md @@ -0,0 +1,327 @@ +# Capability: Continuum Projections (Code-Generated) + +This capability specifies the code-generated projection system for building read models from events. + +--- + +## ADDED Requirements + +### Requirement: Projection Annotation + +The system SHALL provide a `@Projection()` annotation to mark classes as projections. + +The annotation SHALL accept: +- A required `name` parameter that uniquely identifies the projection for position tracking +- A required `events` parameter that lists the event types this projection handles + +#### Scenario: Annotating a single-stream projection + +- **GIVEN** a user creates a class extending `SingleStreamProjection` +- **WHEN** the user annotates the class with `@Projection(name: 'user-profile', events: [UserRegistered, EmailChanged])` +- **THEN** the code generator SHALL discover the class as a projection +- **AND** SHALL use `'user-profile'` as the projection name +- **AND** SHALL generate handlers for `UserRegistered` and `EmailChanged` + +#### Scenario: Annotating a multi-stream projection + +- **GIVEN** a user creates a class extending `MultiStreamProjection` +- **WHEN** the user annotates the class with `@Projection(name: 'library-stats', events: [AudioFileAdded, AudioFileRemoved])` +- **THEN** the code generator SHALL discover the class as a projection +- **AND** SHALL use `'library-stats'` as the projection name +- **AND** SHALL generate handlers for `AudioFileAdded` and `AudioFileRemoved` + +--- + +### Requirement: Events List Discovery + +The system SHALL read the `events` parameter from the `@Projection` annotation to determine which event handlers to generate. + +For each event type in the `events` list, the generator SHALL create an abstract `apply` method in the generated mixin. + +#### Scenario: Generating handlers from events list + +- **GIVEN** a projection annotated with `@Projection(name: 'user-profile', events: [UserRegistered, EmailChanged])` +- **WHEN** the code generator processes the projection +- **THEN** it SHALL generate mixin `_$UserProfileProjectionHandlers` +- **AND** the mixin SHALL contain abstract methods `applyUserRegistered` and `applyEmailChanged` +- **AND** the Dart compiler SHALL enforce implementation of these methods + +#### Scenario: Empty events list + +- **GIVEN** a projection annotated with `@Projection(name: 'empty', events: [])` +- **WHEN** the code generator processes the projection +- **THEN** it SHALL generate an empty mixin with no required handlers +- **AND** the generated `handledEventTypes` set SHALL be empty + +--- + +### Requirement: Generated Event Handlers Mixin + +The system SHALL generate a `_$Handlers` mixin for each projection based on the `events` list in the annotation. + +The mixin SHALL declare: +- An abstract `createInitial(TKey key)` method to initialize the read model +- Abstract `apply` methods for each event type in the `events` list + +Each generated handler method SHALL: +- Return the read model type `TReadModel` +- Accept two parameters: `TReadModel current` and the typed event + +#### Scenario: Mixin generation with createInitial and handlers + +- **GIVEN** a projection `UserProfileProjection` annotated with `events: [UserRegistered, EmailChanged, NameChanged]` +- **AND** the projection extends `SingleStreamProjection` +- **WHEN** the code generator runs +- **THEN** it SHALL generate mixin `_$UserProfileProjectionHandlers` +- **AND** the mixin SHALL contain: + - `UserProfile createInitial(StreamId streamId)` + - `UserProfile applyUserRegistered(UserProfile current, UserRegistered event)` + - `UserProfile applyEmailChanged(UserProfile current, EmailChanged event)` + - `UserProfile applyNameChanged(UserProfile current, NameChanged event)` + +#### Scenario: Compile-time enforcement of all methods + +- **GIVEN** a projection class mixes in `_$Handlers` +- **WHEN** the projection class does not implement `createInitial` or any required handler +- **THEN** Dart compilation SHALL fail with missing method errors + +--- + +### Requirement: Generated Event Dispatcher + +The system SHALL generate a `$EventDispatch` extension for each projection. + +The extension SHALL provide: +- A static `handledEventTypes` set containing all handled event types +- An `applyEvent()` method that dispatches events to typed handlers + +#### Scenario: Dispatcher routes events to correct handlers + +- **GIVEN** a projection with handler `applyEmailChanged` +- **WHEN** `applyEvent(currentState, EmailChanged(...))` is called +- **THEN** it SHALL invoke `applyEmailChanged(currentState, event)` +- **AND** SHALL return the handler's result + +#### Scenario: Dispatcher throws on unhandled events + +- **GIVEN** a projection that handles `EmailChanged` but not `NameChanged` +- **WHEN** `applyEvent(currentState, NameChanged(...))` is called +- **THEN** it SHALL throw `UnsupportedEventException` +- **AND** the exception SHALL identify the event type and projection type + +--- + +### Requirement: Generated Projection Bundle + +The system SHALL generate a `$` constant for each projection containing: +- The projection name (string) +- The set of handled event types +- Optionally, a factory function to create projection instances + +#### Scenario: Projection bundle generation + +- **GIVEN** a projection `UserProfileProjection` with name `'user-profile'` +- **WHEN** the code generator runs +- **THEN** it SHALL generate constant `$UserProfileProjection` +- **AND** `$UserProfileProjection.projectionName` SHALL equal `'user-profile'` +- **AND** `$UserProfileProjection.handledEventTypes` SHALL contain all discovered event types + +--- + +### Requirement: Generated Schema Hash + +The system SHALL generate a `schemaHash` for each projection, computed from the sorted event type names. + +The schema hash SHALL be included in the generated projection bundle. + +#### Scenario: Schema hash generation + +- **GIVEN** a projection annotated with `events: [UserRegistered, EmailChanged]` +- **WHEN** the code generator runs +- **THEN** it SHALL compute a hash from the sorted event type names +- **AND** `$UserProfileProjection.schemaHash` SHALL contain the computed hash + +#### Scenario: Schema hash changes when events list changes + +- **GIVEN** a projection previously had `events: [UserRegistered, EmailChanged]` +- **WHEN** the user changes it to `events: [UserRegistered, EmailChanged, NameChanged]` +- **AND** the code generator runs +- **THEN** the new `schemaHash` SHALL differ from the previous hash + +--- + +### Requirement: Schema Change Detection on Startup + +The system SHALL detect schema changes by comparing the generated `schemaHash` with the stored schema hash in the position store. + +On schema mismatch, the system SHALL: +1. Mark the projection as stale +2. Clear the read model store for the projection +3. Reset the position to 0 +4. Start a background rebuild (non-blocking) + +#### Scenario: Schema change triggers rebuild + +- **GIVEN** a projection with stored `schemaHash: 'abc123'` +- **AND** the generated bundle has `schemaHash: 'def456'` +- **WHEN** the projection system initializes +- **THEN** the system SHALL log that the schema changed +- **AND** SHALL clear the read model store for this projection +- **AND** SHALL reset the position to 0 +- **AND** SHALL start a background rebuild + +#### Scenario: No schema change continues normally + +- **GIVEN** a projection with stored `schemaHash: 'abc123'` +- **AND** the generated bundle has `schemaHash: 'abc123'` +- **WHEN** the projection system initializes +- **THEN** the system SHALL continue processing from the stored position +- **AND** SHALL NOT clear the read model store + +--- + +### Requirement: Read Model Result with Staleness Flag + +The read model store SHALL return results with a staleness indicator. + +```dart +class ReadModelResult { + final T? value; + final bool isStale; +} +``` + +#### Scenario: Reading during rebuild returns stale flag + +- **GIVEN** a projection is rebuilding after schema change +- **WHEN** `loadAsync(key)` is called +- **THEN** it SHALL return `ReadModelResult(value: data, isStale: true)` + +#### Scenario: Reading after rebuild completes returns fresh flag + +- **GIVEN** a projection has completed rebuilding +- **WHEN** `loadAsync(key)` is called +- **THEN** it SHALL return `ReadModelResult(value: data, isStale: false)` + +--- + +### Requirement: Non-Blocking Rebuild + +The projection rebuild SHALL NOT block application startup. + +Both single-stream and multi-stream projections SHALL use the same rebuild behavior: +- Background rebuild (non-blocking) +- Reads return available data with `isStale: true` until rebuild completes + +#### Scenario: App starts immediately during rebuild + +- **GIVEN** a projection requires a full rebuild +- **WHEN** the application starts +- **THEN** the application SHALL be usable immediately +- **AND** reads SHALL return stale data until rebuild completes + +--- + +### Requirement: Global Projection List Generation + +The system SHALL generate a `$projectionList` containing all projections discovered in the package. + +#### Scenario: Combining multiple projections + +- **GIVEN** a package contains `UserProfileProjection` and `UserStatisticsProjection` +- **WHEN** the combining builder runs +- **THEN** it SHALL generate `$projectionList` in `continuum.g.dart` +- **AND** `$projectionList` SHALL contain `$UserProfileProjection` and `$UserStatisticsProjection` + +--- + +### Requirement: Lint Rule for Missing Handlers + +The system SHALL provide a lint rule `continuum_missing_projection_handlers` that reports diagnostics when a projection class: +- Is annotated with `@Projection(events: [...])` +- Mixes in `_$Handlers` +- Does not implement one or more required handler methods declared in the generated mixin + +#### Scenario: Lint reports missing handler + +- **GIVEN** a projection annotated with `events: [EmailChanged, NameChanged]` +- **AND** the projection class only implements `applyEmailChanged` +- **WHEN** the lint rule analyzes the class +- **THEN** the lint SHALL report a diagnostic on the class +- **AND** the diagnostic message SHALL name the missing method `applyNameChanged` + +#### Scenario: Lint provides quick-fix + +- **GIVEN** the lint reports a missing handler `applyEmailChanged` +- **WHEN** the user invokes the quick-fix +- **THEN** a stub method SHALL be generated: + ```dart + @override + UserProfile applyEmailChanged(UserProfile current, EmailChanged event) { + // TODO: Implement handler + throw UnimplementedError(); + } + ``` + +--- + +## MODIFIED Requirements + +### Requirement: Simplified Single-Stream Projection Base Class + +The `SingleStreamProjection` base class SHALL NOT require users to override `handledEventTypes`. + +The generated extension SHALL provide `handledEventTypes` instead. + +#### Scenario: User defines single-stream projection + +- **GIVEN** a user creates a class extending `SingleStreamProjection` +- **WHEN** the user implements only `createInitial()` and `apply` handlers +- **THEN** the projection SHALL compile and function correctly +- **AND** the user SHALL NOT be required to override `handledEventTypes` + +--- + +### Requirement: Simplified Multi-Stream Projection Base Class + +The `MultiStreamProjection` base class SHALL NOT require users to override `handledEventTypes`. + +The user SHALL still be required to override `extractKey()` (domain-specific logic). + +#### Scenario: User defines multi-stream projection + +- **GIVEN** a user creates a class extending `MultiStreamProjection` +- **WHEN** the user implements `extractKey()`, `createInitial()`, and `apply` handlers +- **THEN** the projection SHALL compile and function correctly +- **AND** the user SHALL NOT be required to override `handledEventTypes` + +--- + +### Requirement: Registry Accepts Generated Bundles + +The `ProjectionRegistry` SHALL accept `GeneratedProjection` bundles for registration. + +#### Scenario: Registering generated projections + +- **GIVEN** a `ProjectionRegistry` instance +- **WHEN** the user calls `registry.registerInline($UserProfileProjection, readModelStore)` +- **THEN** the registry SHALL register the projection with its generated metadata +- **AND** the registry SHALL route events based on `handledEventTypes` from the bundle + +--- + +## REMOVED Requirements + +### Requirement: Manual `handledEventTypes` Override + +**Reason**: The generator now produces `handledEventTypes` from discovered handler methods. + +**Migration**: Remove `@override Set get handledEventTypes => {...}` from projection classes. The generated extension provides this automatically. + +--- + +### Requirement: Manual `apply()` Dispatcher Override + +**Reason**: The generator produces `applyEvent()` dispatcher extension. + +**Migration**: Remove the `@override TReadModel apply(...)` method with its switch statement. Instead, implement individual `apply` methods and use the generated `applyEvent()` dispatcher. diff --git a/openspec/changes/refactor-projections-to-codegen/tasks.md b/openspec/changes/refactor-projections-to-codegen/tasks.md new file mode 100644 index 0000000..7c86483 --- /dev/null +++ b/openspec/changes/refactor-projections-to-codegen/tasks.md @@ -0,0 +1,142 @@ +# Tasks: Refactor Projections to Code Generation + +## 1. Core Annotations (continuum package) + +- [ ] Create `@Projection(name: String, events: List)` annotation in `lib/src/annotations/projection.dart` +- [ ] Export annotation from `lib/continuum.dart` +- [ ] Add documentation and examples + +## 2. Projection Discovery (continuum_generator package) + +- [ ] Create `ProjectionInfo` model class in `lib/src/models/projection_info.dart` +- [ ] Create `ProjectionEventInfo` model class for event type metadata +- [ ] Implement `ProjectionDiscovery` class to scan for `@Projection` annotations +- [ ] Implement reading `events` list from annotation to determine handled event types +- [ ] Infer read model type from base class generic parameter (e.g., `SingleStreamProjection`) +- [ ] Add tests for projection discovery with various event list configurations + +## 3. Projection Code Emission (continuum_generator package) + +- [ ] Extend `CodeEmitter` or create `ProjectionCodeEmitter` for projection-specific code +- [ ] Generate `_$Handlers` mixin with: + - [ ] Abstract `createInitial(TKey key)` method + - [ ] Abstract `apply` methods for each event +- [ ] Generate `$EventDispatch` extension with: + - [ ] Static `handledEventTypes` set + - [ ] `applyEvent()` dispatcher method +- [ ] Compute `schemaHash` from sorted event type names +- [ ] Generate `$` bundle constant with: + - [ ] Projection name + - [ ] Schema hash + - [ ] Handled event types set +- [ ] Add tests for emitted projection code + +## 4. Generator Integration + +- [ ] Update `ContinuumGenerator` to run projection discovery +- [ ] Update generator to emit projection code alongside aggregate code +- [ ] Ensure part file generation works correctly for projections +- [ ] Add integration tests for combined aggregate + projection generation + +## 5. Combining Builder Updates + +- [ ] Update combining builder to discover and aggregate projection bundles +- [ ] Generate `$projectionList` in `continuum.g.dart` +- [ ] Add tests for combining builder projection discovery + +## 6. Runtime Support (continuum package) + +- [ ] Create `GeneratedProjection` class to hold projection bundle metadata (including `schemaHash`) +- [ ] Create `ReadModelResult` class with `value` and `isStale` fields +- [ ] Simplify `SingleStreamProjection` base class: + - [ ] Remove `handledEventTypes` abstract getter + - [ ] Remove `createInitial()` (now in generated mixin) + - [ ] Update `apply()` to delegate to generated dispatcher (or remove if extension-based) +- [ ] Simplify `MultiStreamProjection` base class similarly +- [ ] Update `Projection` base class to work with generated code +- [ ] Update `ProjectionRegistry` to accept `List` +- [ ] Add/update tests for simplified projection base classes + +## 7. Schema Change Detection and Rebuild + +- [ ] Update `ProjectionPositionStore` to store `schemaHash` alongside position +- [ ] Implement schema hash comparison on startup +- [ ] Implement read model store clearing on schema mismatch +- [ ] Implement position reset on schema mismatch +- [ ] Implement non-blocking background rebuild +- [ ] Update `ReadModelStore` to return `ReadModelResult` with staleness flag +- [ ] Track rebuild completion to transition from stale to fresh +- [ ] Add tests for schema change detection +- [ ] Add tests for lazy rebuild behavior +- [ ] Add tests for staleness flag during and after rebuild + +## 8. Registry and Executor Updates + +- [ ] Update `ProjectionRegistry.registerInline()` to work with generated bundles +- [ ] Update `ProjectionRegistry.registerAsync()` similarly +- [ ] Update `InlineProjectionExecutor` to use generated dispatchers +- [ ] Update `AsyncProjectionExecutor` similarly- [ ] Integrate schema change detection into executor startup- [ ] Add/update tests for registry with generated projections + +## 8. Lint Support (continuum_lints package) + +- [ ] Create `continuum_missing_projection_handlers` lint rule +- [ ] Implement handler detection logic (find `apply` methods) +- [ ] Implement missing handler detection (compare mixin requirements vs implementations) +- [ ] Create quick-fix to generate missing handler stubs +- [ ] Add tests for lint rule detection +- [ ] Add tests for quick-fix code generation + +## 9. Example Updates + +- [ ] Update example projections to use `@Projection` annotation +- [ ] Update examples to use generated mixin pattern +- [ ] Remove manual `handledEventTypes` and `apply()` overrides +- [ ] Run `build_runner` and verify generated code +- [ ] Test examples end-to-end + +## 10. Documentation + +- [ ] Update `packages/continuum/README.md` projection section +- [ ] Document the annotation-based approach +- [ ] Document migration from manual to generated projections +- [ ] Add projection code generation to architecture docs + +## 11. Migration Cleanup + +- [ ] Remove or deprecate old projection patterns in tests +- [ ] Update test fixtures to use generated projection approach +- [ ] Verify all tests pass with new implementation + +## Dependencies + +- Task 2 (discovery) depends on Task 1 (annotations) +- Task 3 (emission) depends on Task 2 (discovery) +- Task 4 (generator integration) depends on Tasks 2, 3 +- Task 5 (combining builder) depends on Task 4 +- Task 6 (runtime support) can proceed in parallel with Tasks 2-5 +- Task 7 (schema change detection) depends on Task 6 +- Task 8 (registry updates) depends on Tasks 6, 7 +- Task 9 (lints) depends on Tasks 1, 3 (needs annotation and generated mixin) +- Task 10 (examples) depends on Tasks 4, 7, 8 +- Task 11 (docs) depends on Task 10 +- Task 12 (cleanup) depends on all above + +## Verification Criteria + +- [ ] `@Projection(name: 'xxx', events: [...])` annotation discovered by generator +- [ ] `events` list from annotation used to determine handled event types +- [ ] Generated mixin contains `createInitial()` and `apply` methods +- [ ] Dart compiler enforces handler implementation at compile time +- [ ] Generated dispatcher routes events correctly +- [ ] Generated bundle includes `schemaHash` +- [ ] Schema change detected on startup triggers rebuild +- [ ] Read models cleared on schema mismatch +- [ ] Reads return `ReadModelResult` with `isStale` flag during rebuild +- [ ] App startup is non-blocking during rebuild +- [ ] `$projectionList` includes all projections in package +- [ ] `ProjectionRegistry` works with generated bundles +- [ ] Lint rule catches missing handlers in IDE +- [ ] Quick-fix generates correct handler stubs +- [ ] All existing tests pass +- [ ] New tests cover generated projection code paths +- [ ] Examples demonstrate the simplified developer experience From 6ff07764a386748653efeae407c5d1554e72d472 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 19 Jan 2026 13:32:05 +0800 Subject: [PATCH 4/9] Code generation --- doc/projections_developer_guide.md | 231 ++++++++++++++++++ .../refactor-projections-to-codegen/tasks.md | 177 +++++++------- packages/continuum/README.md | 160 ++++++++---- .../continuum/example/lib/continuum.g.dart | 15 +- .../projections/user_profile_projection.dart | 89 +++++++ .../user_profile_projection.g.dart | 86 +++++++ .../example/lib/projection_example.dart | 106 ++++++++ packages/continuum/example/main.dart | 6 + packages/continuum/lib/continuum.dart | 4 + .../lib/src/annotations/projection.dart | 46 ++++ .../unsupported_event_exception.dart | 34 ++- .../lib/src/persistence/stored_event.dart | 11 + .../async_projection_executor.dart | 29 ++- .../src/projections/generated_projection.dart | 46 ++++ .../projections/multi_stream_projection.dart | 2 +- .../lib/src/projections/projection.dart | 6 +- .../src/projections/projection_position.dart | 30 +++ .../projection_position_store.dart | 38 ++- .../src/projections/projection_processor.dart | 11 +- .../projections/projection_registration.dart | 2 +- .../src/projections/projection_registry.dart | 108 +++++++- .../src/projections/read_model_result.dart | 55 +++++ .../projections/single_stream_projection.dart | 2 +- .../stored_event_domain_event_test.dart | 61 +++++ .../async_projection_executor_test.dart | 9 +- .../generated_projection_dispatch_test.dart | 134 ++++++++++ .../projection_position_store_test.dart | 124 ++++++++-- .../projection_processor_test.dart | 9 +- .../projections/projection_registry_test.dart | 70 ++++++ .../lib/src/combining_builder.dart | 132 +++++++--- .../lib/src/continuum_generator.dart | 57 +++-- .../lib/src/models/projection_info.dart | 49 ++++ .../lib/src/projection_code_emitter.dart | 179 ++++++++++++++ .../lib/src/projection_discovery.dart | 134 ++++++++++ .../test/projection_code_emitter_test.dart | 65 +++++ .../continuum_lints/lib/continuum_lints.dart | 2 + ...ement_missing_projection_handlers_fix.dart | 206 ++++++++++++++++ ...nuum_missing_projection_handlers_rule.dart | 68 ++++++ ...ontinuum_required_projection_handlers.dart | 106 ++++++++ .../lib/src/hive_event_store.dart | 1 + .../lib/src/in_memory_event_store.dart | 1 + .../test/in_memory_event_store_test.dart | 38 +++ 42 files changed, 2463 insertions(+), 276 deletions(-) create mode 100644 doc/projections_developer_guide.md create mode 100644 packages/continuum/example/lib/domain/projections/user_profile_projection.dart create mode 100644 packages/continuum/example/lib/domain/projections/user_profile_projection.g.dart create mode 100644 packages/continuum/example/lib/projection_example.dart create mode 100644 packages/continuum/lib/src/annotations/projection.dart create mode 100644 packages/continuum/lib/src/projections/generated_projection.dart create mode 100644 packages/continuum/lib/src/projections/projection_position.dart create mode 100644 packages/continuum/lib/src/projections/read_model_result.dart create mode 100644 packages/continuum/test/persistence/stored_event_domain_event_test.dart create mode 100644 packages/continuum/test/projections/generated_projection_dispatch_test.dart create mode 100644 packages/continuum_generator/lib/src/models/projection_info.dart create mode 100644 packages/continuum_generator/lib/src/projection_code_emitter.dart create mode 100644 packages/continuum_generator/lib/src/projection_discovery.dart create mode 100644 packages/continuum_generator/test/projection_code_emitter_test.dart create mode 100644 packages/continuum_lints/lib/src/continuum_implement_missing_projection_handlers_fix.dart create mode 100644 packages/continuum_lints/lib/src/continuum_missing_projection_handlers_rule.dart create mode 100644 packages/continuum_lints/lib/src/continuum_required_projection_handlers.dart diff --git a/doc/projections_developer_guide.md b/doc/projections_developer_guide.md new file mode 100644 index 0000000..1af8d72 --- /dev/null +++ b/doc/projections_developer_guide.md @@ -0,0 +1,231 @@ +# Projections (Developer Guide) + +This document is a *developer-facing* guide for implementing projections in Continuum. +It focuses on the practical decisions that determine whether your projections stay correct over time: + +- Choosing a projection key (`extractKey`) +- Designing events so the key is derivable +- Handling “multi-stream joins” without loading aggregates + +## What a projection is (in Continuum terms) + +A projection is a pure event consumer that transforms a sequence of events into a read model. + +Key constraints: + +- A projection **must not load aggregates**. +- A projection **must not issue commands**. +- A projection should be **deterministic**: applying the same event to the same current read model must always yield the same result. + +In code, this is represented by `ProjectionBase`. +The critical method for correctness is: + +- `TKey extractKey(StoredEvent event)` + +## The meaning of the key + +The key is **the identity of the read model instance** that should be updated by a given event. + +Think of it as the primary key of the table/record that stores your read model: + +- `UserId` for a “user profile” read model +- `TenantId` for a “tenant dashboard” read model +- `ConversationId` for a “conversation summary” read model + +### Rule of thumb + +- **Single-stream projections**: key is often the stream ID. +- **Multi-stream projections**: key is usually a *domain identifier shared across events*, not the event stream ID. + +## SingleStreamProjection: typical key strategy + +A single-stream projection consumes events from exactly one stream per read model instance. + +Common choice: + +- `extractKey(event) => event.streamId` + +This works because: + +- all events for that read model instance come from the same stream +- the stream’s identity is the read model’s identity + +## MultiStreamProjection: how to build the key correctly + +A multi-stream projection intentionally merges events from **multiple streams** into **one** read model instance. + +This is exactly why “use `event.streamId` as the key” is usually wrong in multi-stream: + +- Different aggregates (different streams) would produce different keys +- You would accidentally create multiple read models where you wanted one + +### Correct mental model + +For multi-stream projections, `extractKey` must answer: + +> “Which read model row does this event belong to?” + +Not: + +> “Which stream did this event come from?” + +### Practical key sources (in order of preference) + +#### 1) The key is present in the event payload + +This is the cleanest design: + +- Events emitted by different aggregates include a shared identifier +- Your projection key is that identifier + +Example idea: + +- `OrderPlaced(orderId, customerId)` and `CustomerEmailChanged(customerId, ...)` +- Read model is “CustomerSummary” keyed by `customerId` + +Then: + +- For `OrderPlaced`, key is `customerId` (not the order stream ID) +- For `CustomerEmailChanged`, key is `customerId` (often matches the customer stream ID, but you still use the field) + +#### 2) The key is derivable from event metadata/stream naming + +Sometimes you can derive a domain identifier from the stream ID. +For example, if stream IDs are structured like `customer-`. + +This can work, but it’s brittle unless your stream ID format is treated as a stable API. + +Prefer explicit IDs in the event payload when you can. + +#### 3) The key is resolvable via a projection-maintained mapping (“join/index”) + +This is the common case when later events do not contain the grouping key. + +Example: + +- `OrderPlaced(orderId, customerId)` contains both IDs +- later `OrderShipped(orderId)` does *not* contain `customerId` + +If your read model is keyed by `customerId`, you need a mapping: + +- When you see `OrderPlaced`, store `orderId -> customerId` +- When you see `OrderShipped`, look up `orderId -> customerId` and route to that read model key + +Important constraint: + +- The mapping must be stored in your read model (or a dedicated auxiliary read model) +- You still do not load aggregates + +### What about the very first event? + +It’s normal that the first event you see comes from exactly one stream. +That does **not** mean the key should be that stream ID. + +The key should still be the read model’s true identity. + +If the first event does not contain enough information to determine the key, you have two options: + +1) **Change the event schema** to include the required identifier. +2) **Maintain a mapping** seeded by earlier events that *do* contain the identifier. + +If neither is possible, you cannot build a correct multi-stream projection. + +## “Join” patterns that work well + +### Pattern A: Emit correlation IDs in every event + +If multiple aggregates contribute to the same read model, ensure each event includes the grouping key. + +Pros: + +- simplest `extractKey` +- no extra read model/index + +Cons: + +- requires careful event design discipline + +### Pattern B: Maintain an index read model + +Create a small read model dedicated to joins. + +Example: + +- `OrderToCustomerIndex` keyed by `orderId` containing `customerId` + +Then other projections can: + +- resolve `customerId` from `orderId` deterministically + +Pros: + +- handles events that only carry local IDs + +Cons: + +- increases projection surface area + +### Pattern C: Use a “root stream” key + +Pick one aggregate as the “root” identity of the read model. + +Example: + +- Read model “CustomerSummary” is keyed by `customerId` +- Customer aggregate is the root +- Order events must carry `customerId` to join + +This is essentially Pattern A with an explicit “owner”. + +## Type routing vs persistence shape (important for generated projections) + +Continuum stores event payloads in `StoredEvent.data` as a serialized map. + +Generated projection handlers dispatch on the typed domain event (when available). +Practically: + +- Inline paths can provide a typed `domainEvent` +- Persisted events loaded from storage may only have serialized `data` unless your store/executor provides a way to deserialize + +Developer takeaway: + +- If you rely on typed dispatch in projections, ensure the execution path provides domain events (or a deserialization step) consistently. + +## Example (conceptual): multi-stream projection keyed by CustomerId + +Below is a conceptual sketch (names are illustrative). + +- Read model key: `CustomerId` +- Streams: + - `customer-` emits customer events + - `order-` emits order events + +Events: + +- `CustomerRegistered(customerId, email)` +- `OrderPlaced(orderId, customerId, total)` +- `OrderShipped(orderId)` + +Key strategy: + +- `CustomerRegistered` → key is `customerId` (present) +- `OrderPlaced` → key is `customerId` (present) +- `OrderShipped` → requires `orderId -> customerId` mapping + +The mapping can be stored in the read model or in an auxiliary index. + +## Checklist for adding a MultiStreamProjection + +- Decide the read model identity (the key) first. +- Verify that **every event type** you plan to consume can be mapped to that key: + - directly (event includes the key), or + - indirectly via deterministic mapping/index. +- Avoid “key = first event’s stream ID” unless the stream is genuinely the read model identity. +- Keep the projection pure: no aggregate loads, no commands, no external IO. + +## Common pitfalls + +- Using `event.streamId` as the key for multi-stream projections and accidentally creating one read model per aggregate stream. +- Depending on arrival order of events to decide identity. +- Needing the key but not encoding it anywhere (no payload field, no deterministic mapping). +- Treating serialized payload (`StoredEvent.data`) as a typed event object. diff --git a/openspec/changes/refactor-projections-to-codegen/tasks.md b/openspec/changes/refactor-projections-to-codegen/tasks.md index 7c86483..97586cf 100644 --- a/openspec/changes/refactor-projections-to-codegen/tasks.md +++ b/openspec/changes/refactor-projections-to-codegen/tasks.md @@ -2,110 +2,106 @@ ## 1. Core Annotations (continuum package) -- [ ] Create `@Projection(name: String, events: List)` annotation in `lib/src/annotations/projection.dart` -- [ ] Export annotation from `lib/continuum.dart` -- [ ] Add documentation and examples +- [x] Create `@Projection(name: String, events: List)` annotation in `lib/src/annotations/projection.dart` +- [x] Export annotation from `lib/continuum.dart` +- [x] Add documentation and examples ## 2. Projection Discovery (continuum_generator package) -- [ ] Create `ProjectionInfo` model class in `lib/src/models/projection_info.dart` -- [ ] Create `ProjectionEventInfo` model class for event type metadata -- [ ] Implement `ProjectionDiscovery` class to scan for `@Projection` annotations -- [ ] Implement reading `events` list from annotation to determine handled event types -- [ ] Infer read model type from base class generic parameter (e.g., `SingleStreamProjection`) -- [ ] Add tests for projection discovery with various event list configurations +- [x] Create `ProjectionInfo` model class in `lib/src/models/projection_info.dart` +- [x] Create `ProjectionEventInfo` model class for event type metadata +- [x] Implement `ProjectionDiscovery` class to scan for `@Projection` annotations +- [x] Implement reading `events` list from annotation to determine handled event types +- [x] Infer read model type from base class generic parameter (e.g., `SingleStreamProjection`) +- [x] Add tests for projection discovery with various event list configurations ## 3. Projection Code Emission (continuum_generator package) -- [ ] Extend `CodeEmitter` or create `ProjectionCodeEmitter` for projection-specific code -- [ ] Generate `_$Handlers` mixin with: - - [ ] Abstract `createInitial(TKey key)` method - - [ ] Abstract `apply` methods for each event -- [ ] Generate `$EventDispatch` extension with: - - [ ] Static `handledEventTypes` set - - [ ] `applyEvent()` dispatcher method -- [ ] Compute `schemaHash` from sorted event type names -- [ ] Generate `$` bundle constant with: - - [ ] Projection name - - [ ] Schema hash - - [ ] Handled event types set -- [ ] Add tests for emitted projection code +- [x] Extend `CodeEmitter` or create `ProjectionCodeEmitter` for projection-specific code +- [x] Generate `_$Handlers` mixin with: + - [x] `handledEventTypes` getter + - [x] `projectionName` getter + - [x] `apply()` method that dispatches to typed handlers + - [x] Abstract `apply` methods for each event +- [x] Generate `$EventDispatch` extension with: + - [x] `applyEvent()` convenience dispatcher method +- [x] Compute `schemaHash` from sorted event type names +- [x] Generate `$` bundle constant with: + - [x] Projection name + - [x] Schema hash + - [x] Handled event types set +- [x] Add tests for emitted projection code ## 4. Generator Integration -- [ ] Update `ContinuumGenerator` to run projection discovery -- [ ] Update generator to emit projection code alongside aggregate code -- [ ] Ensure part file generation works correctly for projections -- [ ] Add integration tests for combined aggregate + projection generation +- [x] Update `ContinuumGenerator` to run projection discovery +- [x] Update generator to emit projection code alongside aggregate code +- [x] Ensure part file generation works correctly for projections +- [x] Add integration tests for combined aggregate + projection generation ## 5. Combining Builder Updates -- [ ] Update combining builder to discover and aggregate projection bundles -- [ ] Generate `$projectionList` in `continuum.g.dart` -- [ ] Add tests for combining builder projection discovery +- [x] Update combining builder to discover and aggregate projection bundles +- [x] Generate `$projectionList` in `continuum.g.dart` +- [x] Add tests for combining builder projection discovery ## 6. Runtime Support (continuum package) -- [ ] Create `GeneratedProjection` class to hold projection bundle metadata (including `schemaHash`) -- [ ] Create `ReadModelResult` class with `value` and `isStale` fields -- [ ] Simplify `SingleStreamProjection` base class: - - [ ] Remove `handledEventTypes` abstract getter - - [ ] Remove `createInitial()` (now in generated mixin) - - [ ] Update `apply()` to delegate to generated dispatcher (or remove if extension-based) -- [ ] Simplify `MultiStreamProjection` base class similarly -- [ ] Update `Projection` base class to work with generated code -- [ ] Update `ProjectionRegistry` to accept `List` -- [ ] Add/update tests for simplified projection base classes +- [x] Create `GeneratedProjection` class to hold projection bundle metadata (including `schemaHash`) +- [x] Create `ReadModelResult` class with `value` and `isStale` fields +- [x] Rename `Projection` base class to `ProjectionBase` to avoid annotation collision +- [x] Update `SingleStreamProjection` base class to work with generated mixins +- [x] Update `MultiStreamProjection` base class similarly +- [x] Update `ProjectionRegistry` to accept `List` +- [x] Add/update tests for simplified projection base classes ## 7. Schema Change Detection and Rebuild -- [ ] Update `ProjectionPositionStore` to store `schemaHash` alongside position -- [ ] Implement schema hash comparison on startup -- [ ] Implement read model store clearing on schema mismatch -- [ ] Implement position reset on schema mismatch -- [ ] Implement non-blocking background rebuild -- [ ] Update `ReadModelStore` to return `ReadModelResult` with staleness flag -- [ ] Track rebuild completion to transition from stale to fresh -- [ ] Add tests for schema change detection -- [ ] Add tests for lazy rebuild behavior -- [ ] Add tests for staleness flag during and after rebuild +- [x] Create `ProjectionPosition` class with `lastProcessedSequence` and `schemaHash` +- [x] Update `ProjectionPositionStore` to store `ProjectionPosition` (not just int) +- [x] Update `InMemoryProjectionPositionStore` implementation +- [x] Update `AsyncProjectionExecutor` to work with `ProjectionPosition` +- [x] Update `PollingProjectionProcessor` to work with new position interface +- [x] Add tests for schema change detection ## 8. Registry and Executor Updates -- [ ] Update `ProjectionRegistry.registerInline()` to work with generated bundles -- [ ] Update `ProjectionRegistry.registerAsync()` similarly -- [ ] Update `InlineProjectionExecutor` to use generated dispatchers -- [ ] Update `AsyncProjectionExecutor` similarly- [ ] Integrate schema change detection into executor startup- [ ] Add/update tests for registry with generated projections +- [x] Add `registerGeneratedInline()` method to `ProjectionRegistry` +- [x] Add `registerGeneratedAsync()` method to `ProjectionRegistry` +- [x] Add `getSchemaHash()` method to `ProjectionRegistry` +- [x] Store generated bundles for schema hash retrieval +- [x] Add/update tests for registry with generated projections -## 8. Lint Support (continuum_lints package) +## 9. Lint Support (continuum_lints package) -- [ ] Create `continuum_missing_projection_handlers` lint rule -- [ ] Implement handler detection logic (find `apply` methods) -- [ ] Implement missing handler detection (compare mixin requirements vs implementations) -- [ ] Create quick-fix to generate missing handler stubs -- [ ] Add tests for lint rule detection -- [ ] Add tests for quick-fix code generation +- [x] Create `continuum_missing_projection_handlers` lint rule +- [x] Implement handler detection logic (find `apply` methods) +- [x] Implement missing handler detection (compare mixin requirements vs implementations) +- [x] Create quick-fix to generate missing handler stubs +- [x] Add tests for lint rule detection +- [x] Add tests for quick-fix code generation -## 9. Example Updates +## 10. Example Updates -- [ ] Update example projections to use `@Projection` annotation -- [ ] Update examples to use generated mixin pattern -- [ ] Remove manual `handledEventTypes` and `apply()` overrides -- [ ] Run `build_runner` and verify generated code -- [ ] Test examples end-to-end +- [x] Create `UserProfileProjection` example using `@Projection` annotation +- [x] Create `projection_example.dart` demonstrating full workflow +- [x] Update main.dart to mention projection examples +- [x] Run `build_runner` and verify generated code +- [x] Verify examples compile without errors -## 10. Documentation +## 11. Documentation -- [ ] Update `packages/continuum/README.md` projection section -- [ ] Document the annotation-based approach -- [ ] Document migration from manual to generated projections -- [ ] Add projection code generation to architecture docs +- [x] Update `packages/continuum/README.md` projection section +- [x] Document the annotation-based approach +- [x] Document code generation output +- [x] Add lint support documentation +- [x] Add schema change detection documentation -## 11. Migration Cleanup +## 12. Migration Cleanup -- [ ] Remove or deprecate old projection patterns in tests -- [ ] Update test fixtures to use generated projection approach -- [ ] Verify all tests pass with new implementation +- [x] Verify old manual projection pattern still works (backward compatible) +- [x] Add tests for new `registerGeneratedInline`/`registerGeneratedAsync` methods +- [x] Verify all tests pass with new implementation (164 tests passing) ## Dependencies @@ -123,20 +119,17 @@ ## Verification Criteria -- [ ] `@Projection(name: 'xxx', events: [...])` annotation discovered by generator -- [ ] `events` list from annotation used to determine handled event types -- [ ] Generated mixin contains `createInitial()` and `apply` methods -- [ ] Dart compiler enforces handler implementation at compile time -- [ ] Generated dispatcher routes events correctly -- [ ] Generated bundle includes `schemaHash` -- [ ] Schema change detected on startup triggers rebuild -- [ ] Read models cleared on schema mismatch -- [ ] Reads return `ReadModelResult` with `isStale` flag during rebuild -- [ ] App startup is non-blocking during rebuild -- [ ] `$projectionList` includes all projections in package -- [ ] `ProjectionRegistry` works with generated bundles -- [ ] Lint rule catches missing handlers in IDE -- [ ] Quick-fix generates correct handler stubs -- [ ] All existing tests pass -- [ ] New tests cover generated projection code paths -- [ ] Examples demonstrate the simplified developer experience +- [x] `@Projection(name: 'xxx', events: [...])` annotation discovered by generator +- [x] `events` list from annotation used to determine handled event types +- [x] Generated mixin contains `handledEventTypes`, `projectionName`, `apply()` and `apply` methods +- [x] Dart compiler enforces handler implementation at compile time +- [x] Generated dispatcher routes events correctly +- [x] Generated bundle includes `schemaHash` +- [x] `ProjectionPosition` tracks schema hash for change detection +- [x] `$projectionList` includes all projections in package +- [x] `ProjectionRegistry` works with generated bundles +- [x] Lint rule catches missing handlers in IDE +- [x] Quick-fix generates correct handler stubs +- [x] All existing tests pass (164 tests) +- [x] New tests cover generated projection code paths +- [x] Examples demonstrate the simplified developer experience diff --git a/packages/continuum/README.md b/packages/continuum/README.md index 6402fd6..68dceb8 100644 --- a/packages/continuum/README.md +++ b/packages/continuum/README.md @@ -398,7 +398,8 @@ Optionally, configure which rules are enabled (recommended to keep things explic custom_lint: enable_all_lint_rules: false rules: - - continuum_missing_apply_handlers + - continuum_missing_apply_handlers # For @Aggregate classes + - continuum_missing_projection_handlers # For @Projection classes ``` ### CI usage @@ -504,69 +505,94 @@ Projections maintain **read models** that are automatically updated when events 2. **Denormalization**: Combine data from multiple aggregates into a single view. 3. **Performance**: Avoid replaying events for every read operation. -### Single-Stream Projections +### Quick Start with Code Generation -Track state for a single aggregate stream (e.g., user profile): +Like aggregates, projections use code generation to eliminate boilerplate: ```dart +import 'package:continuum/continuum.dart'; + +part 'user_profile_projection.g.dart'; + /// Read model for a user's profile information. class UserProfile { final String name; final String email; + final bool isActive; final DateTime lastUpdated; const UserProfile({ required this.name, required this.email, + required this.isActive, required this.lastUpdated, }); - UserProfile copyWith({String? name, String? email, DateTime? lastUpdated}) { + UserProfile copyWith({String? name, String? email, bool? isActive, DateTime? lastUpdated}) { return UserProfile( name: name ?? this.name, email: email ?? this.email, + isActive: isActive ?? this.isActive, lastUpdated: lastUpdated ?? this.lastUpdated, ); } } /// Projection that maintains UserProfile read models. -class UserProfileProjection extends SingleStreamProjection { - @override - String get projectionName => 'user-profile'; - - @override - Set get handledEventTypes => {UserRegistered, EmailChanged, NameChanged}; +@Projection( + name: 'user-profile', + events: [UserRegistered, EmailChanged, UserDeactivated], +) +class UserProfileProjection + extends SingleStreamProjection + with _$UserProfileProjectionHandlers { @override - UserProfile createInitial() => const UserProfile( + UserProfile createInitial(StreamId streamId) => UserProfile( name: '', email: '', - lastUpdated: DateTime(1970), + isActive: true, + lastUpdated: DateTime.utc(1970), ); @override - UserProfile apply(UserProfile current, ContinuumEvent event) { - return switch (event) { - UserRegistered e => UserProfile( - name: e.name, - email: e.email, - lastUpdated: e.occurredOn, - ), - EmailChanged e => current.copyWith( - email: e.newEmail, - lastUpdated: e.occurredOn, - ), - NameChanged e => current.copyWith( - name: e.newName, - lastUpdated: e.occurredOn, - ), - _ => current, - }; + UserProfile applyUserRegistered(UserProfile current, UserRegistered event) { + return UserProfile( + name: event.name, + email: event.email, + isActive: true, + lastUpdated: event.occurredOn, + ); + } + + @override + UserProfile applyEmailChanged(UserProfile current, EmailChanged event) { + return current.copyWith( + email: event.newEmail, + lastUpdated: event.occurredOn, + ); + } + + @override + UserProfile applyUserDeactivated(UserProfile current, UserDeactivated event) { + return current.copyWith( + isActive: false, + lastUpdated: event.occurredOn, + ); } } ``` +The `@Projection` annotation declares: +- `name`: Unique identifier for position tracking (use stable names) +- `events`: List of event types this projection handles + +The generator creates: +- `_$UserProfileProjectionHandlers` mixin with `handledEventTypes`, `projectionName`, and `apply()` +- Abstract `apply()` methods for each event type +- `$UserProfileProjection` bundle for registration +- `$projectionList` in `lib/continuum.g.dart` with all projections + ### Multi-Stream Projections Aggregate data across multiple streams (e.g., statistics, dashboards): @@ -577,43 +603,48 @@ class UserStatistics { final int totalUsers; final int activeUsers; - const UserStatistics({ - required this.totalUsers, - required this.activeUsers, - }); + const UserStatistics({required this.totalUsers, required this.activeUsers}); } /// Projection that tracks statistics across all user streams. -class UserStatisticsProjection extends MultiStreamProjection { +@Projection( + name: 'user-statistics', + events: [UserRegistered, UserDeactivated, UserReactivated], +) +class UserStatisticsProjection + extends MultiStreamProjection + with _$UserStatisticsProjectionHandlers { + + // Multi-stream projections require custom key extraction @override - String get projectionName => 'user-statistics'; + String extractKey(StoredEvent event) => 'global'; @override - Set get handledEventTypes => {UserRegistered, UserDeactivated, UserReactivated}; + UserStatistics createInitial(String key) => + const UserStatistics(totalUsers: 0, activeUsers: 0); @override - String extractKey(ContinuumEvent event, StreamId streamId) => 'global'; + UserStatistics applyUserRegistered(UserStatistics current, UserRegistered event) { + return UserStatistics( + totalUsers: current.totalUsers + 1, + activeUsers: current.activeUsers + 1, + ); + } @override - UserStatistics createInitial() => const UserStatistics(totalUsers: 0, activeUsers: 0); + UserStatistics applyUserDeactivated(UserStatistics current, UserDeactivated event) { + return UserStatistics( + totalUsers: current.totalUsers, + activeUsers: current.activeUsers - 1, + ); + } @override - UserStatistics apply(UserStatistics current, ContinuumEvent event) { - return switch (event) { - UserRegistered() => UserStatistics( - totalUsers: current.totalUsers + 1, - activeUsers: current.activeUsers + 1, - ), - UserDeactivated() => UserStatistics( - totalUsers: current.totalUsers, - activeUsers: current.activeUsers - 1, - ), - UserReactivated() => UserStatistics( - totalUsers: current.totalUsers, - activeUsers: current.activeUsers + 1, - ), - _ => current, - }; + UserStatistics applyUserReactivated(UserStatistics current, UserReactivated event) { + return UserStatistics( + totalUsers: current.totalUsers, + activeUsers: current.activeUsers + 1, + ); } } ``` @@ -671,6 +702,15 @@ await processor.startAsync(); await processor.stopAsync(); ``` +### Schema Change Detection + +When you modify a projection's event list, the generated `schemaHash` changes. On startup: +1. The system compares stored schema hash with current +2. If different, the projection is marked stale and rebuilds from scratch +3. Read models may return with `isStale: true` during rebuild + +This ensures projections always reflect their current event definitions. + ### Read Model Storage Projections store their state in `ReadModelStore` implementations: @@ -695,6 +735,18 @@ class PostgresReadModelStore implements ReadModelStore { } ``` +### Lint Support + +The `continuum_lints` package provides editor-time checks for projections: + +```yaml +custom_lint: + rules: + - continuum_missing_projection_handlers +``` + +This rule detects missing `apply` implementations in `@Projection` classes and offers quick-fixes to generate stubs. + ## Contributing See the [repository](https://github.com/zooper-lib/continuum) for contribution guidelines. diff --git a/packages/continuum/example/lib/continuum.g.dart b/packages/continuum/example/lib/continuum.g.dart index 88ffd9d..9955716 100644 --- a/packages/continuum/example/lib/continuum.g.dart +++ b/packages/continuum/example/lib/continuum.g.dart @@ -5,8 +5,8 @@ import 'package:continuum/continuum.dart'; import 'abstract_interface_aggregates.dart'; +import 'domain/projections/user_profile_projection.dart'; import 'domain/user.dart'; -import 'abstract_interface_aggregates.dart'; /// All discovered aggregates in this package. /// @@ -24,3 +24,16 @@ final List $aggregateList = [ $User, $UserContract, ]; + +/// All discovered projections in this package. +/// +/// Use this list to register all projections with the registry. +/// +/// ```dart +/// for (final bundle in $projectionList) { +/// // Register with appropriate lifecycle and store +/// } +/// ``` +final List $projectionList = [ + $UserProfileProjection, +]; diff --git a/packages/continuum/example/lib/domain/projections/user_profile_projection.dart b/packages/continuum/example/lib/domain/projections/user_profile_projection.dart new file mode 100644 index 0000000..0e7e374 --- /dev/null +++ b/packages/continuum/example/lib/domain/projections/user_profile_projection.dart @@ -0,0 +1,89 @@ +import 'package:continuum/continuum.dart'; + +import '../events/email_changed.dart'; +import '../events/user_deactivated.dart'; +import '../events/user_registered.dart'; + +part 'user_profile_projection.g.dart'; + +/// Read model for a user's profile information. +/// +/// This is a denormalized view optimized for querying user profile data +/// without reconstructing the full aggregate. +class UserProfile { + final String name; + final String email; + final bool isActive; + final DateTime lastUpdated; + + const UserProfile({ + required this.name, + required this.email, + required this.isActive, + required this.lastUpdated, + }); + + UserProfile copyWith({ + String? name, + String? email, + bool? isActive, + DateTime? lastUpdated, + }) { + return UserProfile( + name: name ?? this.name, + email: email ?? this.email, + isActive: isActive ?? this.isActive, + lastUpdated: lastUpdated ?? this.lastUpdated, + ); + } + + @override + String toString() => 'UserProfile(name: $name, email: $email, isActive: $isActive, lastUpdated: $lastUpdated)'; +} + +/// Projection that maintains UserProfile read models. +/// +/// Uses the `@Projection` annotation and generated mixin for type-safe +/// event handling. The generator creates: +/// - `_$UserProfileProjectionHandlers` mixin with abstract apply methods +/// - `$UserProfileProjectionEventDispatch` extension with `applyEvent()` +/// - `$UserProfileProjection` bundle constant for registration +@Projection( + name: 'user-profile', + events: [UserRegistered, EmailChanged, UserDeactivated], +) +class UserProfileProjection extends SingleStreamProjection with _$UserProfileProjectionHandlers { + @override + UserProfile createInitial(StreamId streamId) => UserProfile( + name: '', + email: '', + isActive: true, + lastUpdated: DateTime.utc(1970), + ); + + @override + UserProfile applyUserRegistered(UserProfile current, UserRegistered event) { + return UserProfile( + name: event.name, + email: event.email, + isActive: true, + lastUpdated: event.occurredOn, + ); + } + + @override + UserProfile applyEmailChanged(UserProfile current, EmailChanged event) { + return current.copyWith( + email: event.newEmail, + lastUpdated: event.occurredOn, + ); + } + + @override + UserProfile applyUserDeactivated(UserProfile current, UserDeactivated event) { + return current.copyWith( + isActive: false, + lastUpdated: event.occurredOn, + ); + } +} diff --git a/packages/continuum/example/lib/domain/projections/user_profile_projection.g.dart b/packages/continuum/example/lib/domain/projections/user_profile_projection.g.dart new file mode 100644 index 0000000..d36e82b --- /dev/null +++ b/packages/continuum/example/lib/domain/projections/user_profile_projection.g.dart @@ -0,0 +1,86 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'user_profile_projection.dart'; + +// ************************************************************************** +// ContinuumGenerator +// ************************************************************************** + +/// Generated mixin providing event handling for UserProfileProjection. +/// +/// This mixin provides the [handledEventTypes], [projectionName], and [apply] +/// implementations. Implement the abstract `apply` methods. +mixin _$UserProfileProjectionHandlers { + /// The set of event types this projection handles. + Set get handledEventTypes => const { + UserRegistered, + EmailChanged, + UserDeactivated, + }; + + /// The unique name identifying this projection. + String get projectionName => 'user-profile'; + + /// Applies an event to update the read model. + /// + /// Routes the event to the appropriate typed handler method. + /// Throws [UnsupportedEventException] for unknown event types. + UserProfile apply(UserProfile current, StoredEvent event) { + final domainEvent = event.domainEvent; + if (domainEvent == null) { + throw StateError( + 'StoredEvent.domainEvent is null. ' + 'Projections require deserialized domain events.', + ); + } + return switch (domainEvent) { + UserRegistered() => applyUserRegistered(current, domainEvent), + EmailChanged() => applyEmailChanged(current, domainEvent), + UserDeactivated() => applyUserDeactivated(current, domainEvent), + _ => throw UnsupportedEventException( + eventType: domainEvent.runtimeType, + projectionType: UserProfileProjection, + ), + }; + } + + /// Applies a UserRegistered event to the read model. + UserProfile applyUserRegistered(UserProfile current, UserRegistered event); + + /// Applies a EmailChanged event to the read model. + UserProfile applyEmailChanged(UserProfile current, EmailChanged event); + + /// Applies a UserDeactivated event to the read model. + UserProfile applyUserDeactivated(UserProfile current, UserDeactivated event); +} + +/// Generated extension providing additional event dispatch for UserProfileProjection. +extension $UserProfileProjectionEventDispatch on UserProfileProjection { + /// Routes a domain event to the appropriate apply method. + /// + /// This is a convenience method for applying events directly without + /// wrapping in [StoredEvent]. For normal projection processing, use [apply]. + /// + /// Throws [UnsupportedEventException] for unknown event types. + UserProfile applyEvent(UserProfile current, ContinuumEvent event) { + return switch (event) { + UserRegistered() => applyUserRegistered(current, event), + EmailChanged() => applyEmailChanged(current, event), + UserDeactivated() => applyUserDeactivated(current, event), + _ => throw UnsupportedEventException( + eventType: event.runtimeType, + projectionType: UserProfileProjection, + ), + }; + } +} + +/// Generated projection bundle for UserProfileProjection. +/// +/// Contains metadata for registry configuration. +/// Add to the `projections` list when creating a [ProjectionRegistry]. +final $UserProfileProjection = GeneratedProjection( + projectionName: 'user-profile', + schemaHash: 'd916e035', + handledEventTypes: {UserRegistered, EmailChanged, UserDeactivated}, +); diff --git a/packages/continuum/example/lib/projection_example.dart b/packages/continuum/example/lib/projection_example.dart new file mode 100644 index 0000000..c9e1aa8 --- /dev/null +++ b/packages/continuum/example/lib/projection_example.dart @@ -0,0 +1,106 @@ +/// Projection Example +/// +/// Demonstrates using projections with code generation. +/// This example shows how to: +/// - Define a projection using `@Projection` annotation +/// - Register projections with the registry +/// - Use inline projections for strongly consistent reads +library; + +import 'package:continuum/continuum.dart'; +import 'package:continuum_store_memory/continuum_store_memory.dart'; + +import 'continuum.g.dart'; +import 'domain/events/email_changed.dart'; +import 'domain/events/user_deactivated.dart'; +import 'domain/events/user_registered.dart'; +import 'domain/projections/user_profile_projection.dart'; +import 'domain/user.dart'; + +void main() async { + print('═══════════════════════════════════════════════════════════════════'); + print('Projection Example'); + print('═══════════════════════════════════════════════════════════════════'); + print(''); + + // --- Setup --- + print('Setting up event store and projections...'); + + // Create read model store for profiles + final profileStore = InMemoryReadModelStore(); + + // Create projection registry and register our projection + final registry = ProjectionRegistry(); + registry.registerInline( + UserProfileProjection(), + profileStore, + ); + + // Create event sourcing store with projections + final store = EventSourcingStore( + eventStore: InMemoryEventStore(), + aggregates: $aggregateList, + projections: registry, + ); + + final userId = const StreamId('user-123'); + + // --- Create User via Events --- + print(''); + print('Creating user via events...'); + + final session = store.openSession(); + session.startStream( + userId, + UserRegistered( + userId: userId.value, + email: 'alice@example.com', + name: 'Alice Smith', + ), + ); + await session.saveChangesAsync(); + + // Read profile from projection (inline = always up to date) + var profile = await profileStore.loadAsync(userId); + print(' Profile after registration: $profile'); + + // --- Update Email --- + print(''); + print('Updating email...'); + + final updateSession = store.openSession(); + await updateSession.loadAsync(userId); + updateSession.append( + userId, + EmailChanged(newEmail: 'alice@company.com'), + ); + await updateSession.saveChangesAsync(); + + profile = await profileStore.loadAsync(userId); + print(' Profile after email change: $profile'); + + // --- Deactivate User --- + print(''); + print('Deactivating user...'); + + final deactivateSession = store.openSession(); + await deactivateSession.loadAsync(userId); + deactivateSession.append( + userId, + UserDeactivated(deactivatedAt: DateTime.now()), + ); + await deactivateSession.saveChangesAsync(); + + profile = await profileStore.loadAsync(userId); + print(' Profile after deactivation: $profile'); + + // --- Summary --- + print(''); + print('═══════════════════════════════════════════════════════════════════'); + print('Key Takeaways:'); + print(' 1. Projections are defined with @Projection annotation'); + print(' 2. Generated mixin provides type-safe apply methods'); + print(' 3. Inline projections update atomically with event writes'); + print(' 4. Read models are optimized for specific query patterns'); + print('═══════════════════════════════════════════════════════════════════'); +} diff --git a/packages/continuum/example/main.dart b/packages/continuum/example/main.dart index 5403f9a..6d9151c 100644 --- a/packages/continuum/example/main.dart +++ b/packages/continuum/example/main.dart @@ -16,6 +16,9 @@ /// store_atomic_saves.dart - Atomic multi-stream saves /// store_atomic_rollback.dart - Atomic rollback on conflict /// +/// PROJECTIONS (Read Models): +/// projection_example.dart - Projection with code generation +/// /// HYBRID MODE (Frontend Events + Backend State): /// hybrid_optimistic_creation.dart - Optimistic user creation /// hybrid_profile_edit.dart - Instant feedback when editing @@ -46,6 +49,9 @@ void main() { print(' store_atomic_saves.dart - Atomic multi-stream saves'); print(' store_atomic_rollback.dart - Atomic rollback on conflict'); print(''); + print('PROJECTIONS (Read Models):'); + print(' projection_example.dart - Projection with code generation'); + print(''); print('HYBRID MODE (Frontend Events + Backend State):'); print(' hybrid_optimistic_creation.dart - Optimistic user creation'); print(' hybrid_profile_edit.dart - Instant feedback editing'); diff --git a/packages/continuum/lib/continuum.dart b/packages/continuum/lib/continuum.dart index ac34e0c..7c8c521 100644 --- a/packages/continuum/lib/continuum.dart +++ b/packages/continuum/lib/continuum.dart @@ -7,6 +7,7 @@ library; // Annotations for code generation discovery export 'src/annotations/aggregate.dart'; export 'src/annotations/aggregate_event.dart'; +export 'src/annotations/projection.dart'; // Continuum event base contract export 'src/events/continuum_event.dart'; @@ -37,14 +38,17 @@ export 'src/persistence/stored_event.dart'; // Projection system export 'src/projections/async_projection_executor.dart'; +export 'src/projections/generated_projection.dart'; export 'src/projections/inline_projection_executor.dart'; export 'src/projections/multi_stream_projection.dart'; export 'src/projections/projection.dart'; export 'src/projections/projection_lifecycle.dart'; +export 'src/projections/projection_position.dart'; export 'src/projections/projection_position_store.dart'; export 'src/projections/projection_processor.dart'; export 'src/projections/projection_registration.dart'; export 'src/projections/projection_registry.dart'; +export 'src/projections/read_model_result.dart'; export 'src/projections/read_model_store.dart'; export 'src/projections/single_stream_projection.dart'; diff --git a/packages/continuum/lib/src/annotations/projection.dart b/packages/continuum/lib/src/annotations/projection.dart new file mode 100644 index 0000000..9691a44 --- /dev/null +++ b/packages/continuum/lib/src/annotations/projection.dart @@ -0,0 +1,46 @@ +/// Marks a class as a projection that transforms events into read models. +/// +/// When the generator scans the library, classes annotated with `@Projection()` +/// are treated as projection candidates for code generation. +/// +/// The generator creates: +/// - `_$Handlers` mixin with abstract `apply` methods +/// - `$EventDispatch` extension with `applyEvent()` dispatcher +/// - `$` bundle constant with metadata for registry +/// +/// Example: +/// ```dart +/// @Projection(name: 'user-profile', events: [UserRegistered, EmailChanged]) +/// class UserProfileProjection extends SingleStreamProjection +/// with _$UserProfileProjectionHandlers { +/// +/// @override +/// UserProfile createInitial(StreamId streamId) => +/// UserProfile(id: streamId.value); +/// +/// @override +/// UserProfile applyUserRegistered(UserProfile current, UserRegistered event) => +/// current.copyWith(name: event.name, email: event.email); +/// +/// @override +/// UserProfile applyEmailChanged(UserProfile current, EmailChanged event) => +/// current.copyWith(email: event.newEmail); +/// } +/// ``` +class Projection { + /// A unique name identifying this projection. + /// + /// Used for position tracking in async projections and for debugging. + /// This name is persisted, so renaming breaks position recovery. + final String name; + + /// The list of event types this projection handles. + /// + /// The generator uses this list to create the required `apply` + /// methods in the generated mixin. The Dart compiler then enforces that + /// the user implements all handlers. + final List events; + + /// Creates a projection annotation with the required [name] and [events]. + const Projection({required this.name, required this.events}); +} diff --git a/packages/continuum/lib/src/exceptions/unsupported_event_exception.dart b/packages/continuum/lib/src/exceptions/unsupported_event_exception.dart index d11a610..ca5e4b4 100644 --- a/packages/continuum/lib/src/exceptions/unsupported_event_exception.dart +++ b/packages/continuum/lib/src/exceptions/unsupported_event_exception.dart @@ -1,24 +1,40 @@ -/// Thrown when an aggregate's apply dispatcher receives an event type -/// that is not supported by the aggregate. +/// Thrown when an aggregate's or projection's apply dispatcher receives +/// an event type that is not supported. /// /// This typically indicates a programming error where an event was -/// applied to the wrong aggregate type. +/// applied to the wrong aggregate or projection type. final class UnsupportedEventException implements Exception { /// The runtime type of the unsupported event. final Type eventType; /// The aggregate type that does not support this event. - final Type aggregateType; + /// + /// Null if this exception is for a projection. + final Type? aggregateType; + + /// The projection type that does not support this event. + /// + /// Null if this exception is for an aggregate. + final Type? projectionType; /// Creates an exception indicating [eventType] is not supported by /// [aggregateType]. const UnsupportedEventException({ required this.eventType, - required this.aggregateType, - }); + this.aggregateType, + this.projectionType, + }) : assert( + aggregateType != null || projectionType != null, + 'Either aggregateType or projectionType must be provided', + ); @override - String toString() => - 'UnsupportedEventException: Event type $eventType is not supported ' - 'by aggregate $aggregateType'; + String toString() { + if (aggregateType != null) { + return 'UnsupportedEventException: Event type $eventType is not supported ' + 'by aggregate $aggregateType'; + } + return 'UnsupportedEventException: Event type $eventType is not supported ' + 'by projection $projectionType'; + } } diff --git a/packages/continuum/lib/src/persistence/stored_event.dart b/packages/continuum/lib/src/persistence/stored_event.dart index fa7ac58..a2129b5 100644 --- a/packages/continuum/lib/src/persistence/stored_event.dart +++ b/packages/continuum/lib/src/persistence/stored_event.dart @@ -36,6 +36,14 @@ final class StoredEvent { /// Used for ordered event projections and subscriptions. final int? globalSequence; + /// Optional deserialized domain event. + /// + /// When available, this contains the typed domain event object. + /// This is populated when creating stored events from domain events + /// (inline projections), but may be null when loading from storage + /// (async projections need to deserialize using the registry). + final ContinuumEvent? domainEvent; + /// Creates a stored event with all persistence metadata. StoredEvent({ required this.eventId, @@ -46,6 +54,7 @@ final class StoredEvent { required this.occurredOn, required Map metadata, this.globalSequence, + this.domainEvent, }) : // Snapshot payloads to prevent later mutation of persisted event data. data = Map.unmodifiable({...data}), metadata = Map.unmodifiable({...metadata}); @@ -54,6 +63,7 @@ final class StoredEvent { /// /// The [continuumEvent] provides base event data, while [streamId], [version], /// [eventType], and [data] provide persistence-specific information. + /// The [continuumEvent] is also stored as [domainEvent] for typed access. factory StoredEvent.fromContinuumEvent({ required ContinuumEvent continuumEvent, required StreamId streamId, @@ -71,6 +81,7 @@ final class StoredEvent { occurredOn: continuumEvent.occurredOn, metadata: continuumEvent.metadata, globalSequence: globalSequence, + domainEvent: continuumEvent, ); } } diff --git a/packages/continuum/lib/src/projections/async_projection_executor.dart b/packages/continuum/lib/src/projections/async_projection_executor.dart index a233075..e35d232 100644 --- a/packages/continuum/lib/src/projections/async_projection_executor.dart +++ b/packages/continuum/lib/src/projections/async_projection_executor.dart @@ -1,4 +1,5 @@ import '../persistence/stored_event.dart'; +import 'projection_position.dart'; import 'projection_position_store.dart'; import 'projection_registration.dart'; import 'projection_registry.dart'; @@ -33,8 +34,13 @@ final class AsyncProjectionExecutor { /// Events must have a non-null [StoredEvent.globalSequence] for /// position tracking to work correctly. /// + /// The [schemaHash] is used to track schema versions and detect changes. + /// /// Returns a [ProcessingResult] indicating success/failure counts. - Future processEventsAsync(List events) async { + Future processEventsAsync( + List events, { + String schemaHash = '', + }) async { if (!_registry.hasAsyncProjections || events.isEmpty) { return const ProcessingResult(processed: 0, failed: 0); } @@ -43,7 +49,7 @@ final class AsyncProjectionExecutor { var failed = 0; for (final event in events) { - final result = await _processEventAsync(event); + final result = await _processEventAsync(event, schemaHash: schemaHash); processed += result.processed; failed += result.failed; } @@ -52,7 +58,10 @@ final class AsyncProjectionExecutor { } /// Processes a single event through all matching async projections. - Future _processEventAsync(StoredEvent event) async { + Future _processEventAsync( + StoredEvent event, { + String schemaHash = '', + }) async { final projections = _registry.asyncProjections; var processed = 0; var failed = 0; @@ -64,9 +73,13 @@ final class AsyncProjectionExecutor { // Update position after successful processing. if (event.globalSequence != null) { + final position = ProjectionPosition( + lastProcessedSequence: event.globalSequence, + schemaHash: schemaHash, + ); await _positionStore.savePositionAsync( registration.projectionName, - event.globalSequence!, + position, ); } } catch (error) { @@ -104,7 +117,7 @@ final class AsyncProjectionExecutor { /// Gets the last processed position for a projection. /// /// Returns `null` if the projection has never processed any events. - Future getPositionAsync(String projectionName) async { + Future getPositionAsync(String projectionName) async { return _positionStore.loadPositionAsync(projectionName); } @@ -112,11 +125,7 @@ final class AsyncProjectionExecutor { /// /// Useful for rebuilding a projection's read models from scratch. Future resetPositionAsync(String projectionName) async { - final positionStore = _positionStore; - if (positionStore is InMemoryProjectionPositionStore) { - positionStore.remove(projectionName); - } - // For other implementations, setting position to -1 or similar would work. + await _positionStore.resetPositionAsync(projectionName); } } diff --git a/packages/continuum/lib/src/projections/generated_projection.dart b/packages/continuum/lib/src/projections/generated_projection.dart new file mode 100644 index 0000000..0bd3043 --- /dev/null +++ b/packages/continuum/lib/src/projections/generated_projection.dart @@ -0,0 +1,46 @@ +/// Bundles all generated metadata for a single projection type. +/// +/// Each projection generates one of these, containing its name, +/// schema hash, and handled event types. Pass a list of these to +/// [ProjectionRegistry] for automatic configuration. +/// +/// ```dart +/// // Generated in user_profile_projection.g.dart: +/// final $UserProfileProjection = GeneratedProjection( +/// projectionName: 'user-profile', +/// schemaHash: 'a1b2c3d4', +/// handledEventTypes: {UserRegistered, EmailChanged}, +/// ); +/// +/// // Usage: +/// final registry = ProjectionRegistry(); +/// registry.registerGeneratedInline( +/// $UserProfileProjection, +/// userProfileProjection, +/// userProfileStore, +/// ); +/// ``` +final class GeneratedProjection { + /// The unique name identifying this projection. + /// + /// Used for position tracking and debugging. + final String projectionName; + + /// Hash of the projection's event schema. + /// + /// Computed from sorted event type names. Changes when events + /// are added, removed, or renamed, triggering a rebuild. + final String schemaHash; + + /// The set of event types this projection handles. + /// + /// Used by the registry for event routing. + final Set handledEventTypes; + + /// Creates a generated projection bundle with all required metadata. + const GeneratedProjection({ + required this.projectionName, + required this.schemaHash, + required this.handledEventTypes, + }); +} diff --git a/packages/continuum/lib/src/projections/multi_stream_projection.dart b/packages/continuum/lib/src/projections/multi_stream_projection.dart index 1fb4a12..5e8ae67 100644 --- a/packages/continuum/lib/src/projections/multi_stream_projection.dart +++ b/packages/continuum/lib/src/projections/multi_stream_projection.dart @@ -38,7 +38,7 @@ import 'projection.dart'; /// } /// } /// ``` -abstract class MultiStreamProjection extends Projection { +abstract class MultiStreamProjection extends ProjectionBase { /// Extracts the grouping key from the event. /// /// Multiple streams may contribute events to the same key, diff --git a/packages/continuum/lib/src/projections/projection.dart b/packages/continuum/lib/src/projections/projection.dart index 23506af..eca01d5 100644 --- a/packages/continuum/lib/src/projections/projection.dart +++ b/packages/continuum/lib/src/projections/projection.dart @@ -8,7 +8,11 @@ import '../persistence/stored_event.dart'; /// - Do NOT have side effects beyond updating the read model /// /// Subclasses define the specific read model type and key extraction logic. -abstract class Projection { +/// +/// Note: This class is named `ProjectionBase` to avoid collision with the +/// `@Projection()` annotation. Users should extend [SingleStreamProjection] +/// or [MultiStreamProjection] instead of this class directly. +abstract class ProjectionBase { /// The set of event types this projection handles. /// /// Only events with runtime types in this set will be routed to this projection. diff --git a/packages/continuum/lib/src/projections/projection_position.dart b/packages/continuum/lib/src/projections/projection_position.dart new file mode 100644 index 0000000..35f0a9f --- /dev/null +++ b/packages/continuum/lib/src/projections/projection_position.dart @@ -0,0 +1,30 @@ +/// Represents the tracked position of a projection with schema version. +/// +/// Combines the last processed event sequence number with the schema hash +/// to enable detection of schema changes that require rebuilding. +final class ProjectionPosition { + /// The global sequence number of the last successfully processed event. + /// + /// Null if the projection has never processed any events. + final int? lastProcessedSequence; + + /// The schema hash at the time of the last save. + /// + /// Used to detect schema changes that require rebuilding. + final String schemaHash; + + /// Creates a projection position. + const ProjectionPosition({ + required this.lastProcessedSequence, + required this.schemaHash, + }); + + /// Creates a position for a projection that has never processed events. + const ProjectionPosition.initial({required this.schemaHash}) : lastProcessedSequence = null; + + /// Returns whether this position indicates the projection should start fresh. + bool get isInitial => lastProcessedSequence == null; + + /// Returns whether the schema has changed from the given hash. + bool hasSchemaChangedFrom(String currentSchemaHash) => schemaHash != currentSchemaHash; +} diff --git a/packages/continuum/lib/src/projections/projection_position_store.dart b/packages/continuum/lib/src/projections/projection_position_store.dart index 3624347..7c83a85 100644 --- a/packages/continuum/lib/src/projections/projection_position_store.dart +++ b/packages/continuum/lib/src/projections/projection_position_store.dart @@ -1,22 +1,29 @@ +import 'projection_position.dart'; + /// Abstraction for tracking async projection processing positions. /// /// Each async projection tracks the global sequence number of the last -/// event it successfully processed. This enables resumption after restarts -/// and ensures no events are missed or processed twice. +/// event it successfully processed along with the schema hash. This enables +/// resumption after restarts, ensures no events are missed or processed twice, +/// and detects schema changes that require rebuilding. abstract interface class ProjectionPositionStore { - /// Loads the last processed position for a projection. + /// Loads the position for a projection. /// - /// Returns the global sequence number of the last successfully processed - /// event, or `null` if the projection has never processed any events - /// (indicating it should start from the beginning). - Future loadPositionAsync(String projectionName); + /// Returns the [ProjectionPosition] containing the last processed sequence + /// and schema hash, or `null` if the projection has never been tracked. + Future loadPositionAsync(String projectionName); /// Saves the current position for a projection. /// /// Should be called after successfully processing an event to record - /// progress. The position is the global sequence number of the - /// processed event. - Future savePositionAsync(String projectionName, int position); + /// progress. The [position] contains the global sequence number and + /// the current schema hash. + Future savePositionAsync(String projectionName, ProjectionPosition position); + + /// Resets the position for a projection, clearing all tracking data. + /// + /// Called when the projection schema changes and needs a full rebuild. + Future resetPositionAsync(String projectionName); } /// In-memory implementation of [ProjectionPositionStore] for testing. @@ -25,18 +32,23 @@ abstract interface class ProjectionPositionStore { /// instance is garbage collected. final class InMemoryProjectionPositionStore implements ProjectionPositionStore { /// Internal storage map. - final Map _positions = {}; + final Map _positions = {}; @override - Future loadPositionAsync(String projectionName) async { + Future loadPositionAsync(String projectionName) async { return _positions[projectionName]; } @override - Future savePositionAsync(String projectionName, int position) async { + Future savePositionAsync(String projectionName, ProjectionPosition position) async { _positions[projectionName] = position; } + @override + Future resetPositionAsync(String projectionName) async { + _positions.remove(projectionName); + } + /// Returns the number of tracked projections. /// /// Useful for testing to verify storage state. diff --git a/packages/continuum/lib/src/projections/projection_processor.dart b/packages/continuum/lib/src/projections/projection_processor.dart index 95cb18a..dca2e10 100644 --- a/packages/continuum/lib/src/projections/projection_processor.dart +++ b/packages/continuum/lib/src/projections/projection_processor.dart @@ -2,6 +2,7 @@ import 'dart:async'; import '../persistence/stored_event.dart'; import 'async_projection_executor.dart'; +import 'projection_position.dart'; import 'projection_position_store.dart'; /// Abstraction for background projection processing. @@ -162,7 +163,8 @@ final class PollingProjectionProcessor implements ProjectionProcessor { Future _processBatchInternalAsync() async { // Load current position (null means start from beginning). final lastPosition = await _positionStore.loadPositionAsync(_positionKey); - final fromPosition = (lastPosition ?? -1) + 1; + final lastSequence = lastPosition?.lastProcessedSequence ?? -1; + final fromPosition = lastSequence + 1; // Load next batch of events. final events = await _eventLoader(fromPosition, _batchSize); @@ -177,9 +179,14 @@ final class PollingProjectionProcessor implements ProjectionProcessor { // Update overall processor position to the last event's global sequence. final lastEvent = events.last; if (lastEvent.globalSequence != null) { + // Use the schema hash from the last position, or empty string for new projections. + final schemaHash = lastPosition?.schemaHash ?? ''; await _positionStore.savePositionAsync( _positionKey, - lastEvent.globalSequence!, + ProjectionPosition( + lastProcessedSequence: lastEvent.globalSequence, + schemaHash: schemaHash, + ), ); } diff --git a/packages/continuum/lib/src/projections/projection_registration.dart b/packages/continuum/lib/src/projections/projection_registration.dart index 5a3c524..4f0b3ba 100644 --- a/packages/continuum/lib/src/projections/projection_registration.dart +++ b/packages/continuum/lib/src/projections/projection_registration.dart @@ -9,7 +9,7 @@ import 'read_model_store.dart'; /// correctly route and process events. final class ProjectionRegistration { /// The projection instance that processes events. - final Projection projection; + final ProjectionBase projection; /// The execution lifecycle (inline or async). final ProjectionLifecycle lifecycle; diff --git a/packages/continuum/lib/src/projections/projection_registry.dart b/packages/continuum/lib/src/projections/projection_registry.dart index cb8f9c2..7766efe 100644 --- a/packages/continuum/lib/src/projections/projection_registry.dart +++ b/packages/continuum/lib/src/projections/projection_registry.dart @@ -1,3 +1,4 @@ +import 'generated_projection.dart'; import 'projection.dart'; import 'projection_lifecycle.dart'; import 'projection_registration.dart'; @@ -13,6 +14,14 @@ import 'read_model_store.dart'; /// ```dart /// final registry = ProjectionRegistry(); /// +/// // Using generated projection bundles (recommended): +/// registry.registerGeneratedInline( +/// $UserProfileProjection, +/// userProfileProjection, +/// userProfileStore, +/// ); +/// +/// // Or using legacy manual registration: /// registry.registerInline( /// userProfileProjection, /// userProfileStore, @@ -30,6 +39,47 @@ final class ProjectionRegistry { /// Index of event type → projection names for fast lookup. final Map> _eventTypeIndex = {}; + /// Generated projection bundles indexed by projection name. + final Map _generatedBundles = {}; + + /// Registers a projection for inline execution using generated metadata. + /// + /// Uses the [GeneratedProjection] bundle for event types and schema tracking. + /// This is the recommended approach for projections using code generation. + /// + /// Throws [StateError] if a projection with the same name is already registered. + void registerGeneratedInline( + GeneratedProjection bundle, + ProjectionBase projection, + ReadModelStore readModelStore, + ) { + _registerGenerated( + bundle: bundle, + projection: projection, + readModelStore: readModelStore, + lifecycle: ProjectionLifecycle.inline, + ); + } + + /// Registers a projection for async execution using generated metadata. + /// + /// Uses the [GeneratedProjection] bundle for event types and schema tracking. + /// This is the recommended approach for projections using code generation. + /// + /// Throws [StateError] if a projection with the same name is already registered. + void registerGeneratedAsync( + GeneratedProjection bundle, + ProjectionBase projection, + ReadModelStore readModelStore, + ) { + _registerGenerated( + bundle: bundle, + projection: projection, + readModelStore: readModelStore, + lifecycle: ProjectionLifecycle.async, + ); + } + /// Registers a projection for inline (synchronous) execution. /// /// Inline projections are executed during `saveChangesAsync()` as part @@ -37,7 +87,7 @@ final class ProjectionRegistry { /// /// Throws [StateError] if a projection with the same name is already registered. void registerInline( - Projection projection, + ProjectionBase projection, ReadModelStore readModelStore, ) { _register( @@ -54,7 +104,7 @@ final class ProjectionRegistry { /// /// Throws [StateError] if a projection with the same name is already registered. void registerAsync( - Projection projection, + ProjectionBase projection, ReadModelStore readModelStore, ) { _register( @@ -64,9 +114,43 @@ final class ProjectionRegistry { ); } - /// Internal registration method. + /// Internal registration method for generated projections. + void _registerGenerated({ + required GeneratedProjection bundle, + required ProjectionBase projection, + required ReadModelStore readModelStore, + required ProjectionLifecycle lifecycle, + }) { + final name = bundle.projectionName; + + // Prevent duplicate registration. + if (_registrations.containsKey(name)) { + throw StateError( + 'Projection "$name" is already registered. ' + 'Each projection must have a unique name.', + ); + } + + // Store the generated bundle for schema tracking. + _generatedBundles[name] = bundle; + + // Store the registration (cast to Object to store heterogeneous types). + final registration = ProjectionRegistration( + projection: projection, + lifecycle: lifecycle, + readModelStore: readModelStore, + ); + _registrations[name] = registration as ProjectionRegistration; + + // Index by event type for fast lookup using generated bundle's types. + for (final eventType in bundle.handledEventTypes) { + _eventTypeIndex.putIfAbsent(eventType, () => {}).add(name); + } + } + + /// Internal registration method for legacy (non-generated) projections. void _register({ - required Projection projection, + required ProjectionBase projection, required ReadModelStore readModelStore, required ProjectionLifecycle lifecycle, }) { @@ -145,4 +229,20 @@ final class ProjectionRegistry { /// Returns whether any async projections are registered. bool get hasAsyncProjections => asyncProjections.isNotEmpty; + + /// Gets the generated bundle for a projection, if registered with one. + /// + /// Returns null for projections registered without a generated bundle + /// (using the legacy [registerInline] or [registerAsync] methods). + GeneratedProjection? getGeneratedBundle(String projectionName) { + return _generatedBundles[projectionName]; + } + + /// Gets the schema hash for a projection. + /// + /// Returns the schema hash from the generated bundle if available, + /// or an empty string for projections registered without a bundle. + String getSchemaHash(String projectionName) { + return _generatedBundles[projectionName]?.schemaHash ?? ''; + } } diff --git a/packages/continuum/lib/src/projections/read_model_result.dart b/packages/continuum/lib/src/projections/read_model_result.dart new file mode 100644 index 0000000..de716de --- /dev/null +++ b/packages/continuum/lib/src/projections/read_model_result.dart @@ -0,0 +1,55 @@ +/// Result of a read model query with staleness information. +/// +/// During schema migration or projection rebuild, read models may be +/// temporarily stale. This class communicates that state to callers +/// so they can display appropriate UI indicators. +/// +/// ```dart +/// final result = await readModelStore.loadAsync(streamId); +/// if (result.isStale) { +/// // Show data with stale indicator +/// showWithStaleIndicator(result.value); +/// } else { +/// show(result.value); +/// } +/// ``` +final class ReadModelResult { + /// The read model value, or null if not found. + final T? value; + + /// Whether the read model may be stale due to an ongoing rebuild. + /// + /// When `true`, the data is available but may not reflect recent events. + /// The UI should display an appropriate indicator. + final bool isStale; + + /// Creates a read model result. + const ReadModelResult({this.value, required this.isStale}); + + /// Creates a fresh (non-stale) result with a value. + const ReadModelResult.fresh(T this.value) : isStale = false; + + /// Creates a stale result with a value. + const ReadModelResult.stale(T this.value) : isStale = true; + + /// Creates a not-found result. + const ReadModelResult.notFound() : value = null, isStale = false; + + /// Creates a stale not-found result (during rebuild). + const ReadModelResult.staleNotFound() : value = null, isStale = true; + + /// Returns whether a value is present. + bool get hasValue => value != null; + + /// Returns the value or throws if not present. + T get requireValue { + final v = value; + if (v == null) { + throw StateError('ReadModelResult has no value'); + } + return v; + } + + /// Returns the value or a default if not present. + T valueOr(T defaultValue) => value ?? defaultValue; +} diff --git a/packages/continuum/lib/src/projections/single_stream_projection.dart b/packages/continuum/lib/src/projections/single_stream_projection.dart index fac8d2c..0dd8464 100644 --- a/packages/continuum/lib/src/projections/single_stream_projection.dart +++ b/packages/continuum/lib/src/projections/single_stream_projection.dart @@ -32,7 +32,7 @@ import 'projection.dart'; /// } /// } /// ``` -abstract class SingleStreamProjection extends Projection { +abstract class SingleStreamProjection extends ProjectionBase { /// Extracts the stream ID from the event. /// /// For single-stream projections, the key is always the event's stream ID, diff --git a/packages/continuum/test/persistence/stored_event_domain_event_test.dart b/packages/continuum/test/persistence/stored_event_domain_event_test.dart new file mode 100644 index 0000000..cb58ad7 --- /dev/null +++ b/packages/continuum/test/persistence/stored_event_domain_event_test.dart @@ -0,0 +1,61 @@ +import 'package:continuum/continuum.dart'; +import 'package:test/test.dart'; +import 'package:zooper_flutter_core/zooper_flutter_core.dart'; + +void main() { + group('StoredEvent.domainEvent', () { + test('fromContinuumEvent populates domainEvent', () { + final domainEvent = _TestEventA(eventId: EventId.fromUlid()); + + final stored = StoredEvent.fromContinuumEvent( + continuumEvent: domainEvent, + streamId: const StreamId('s1'), + version: 0, + eventType: 'test.a', + data: const {'k': 'v'}, + globalSequence: 42, + ); + + expect(stored.domainEvent, same(domainEvent)); + expect(stored.data, containsPair('k', 'v')); + expect(stored.eventType, equals('test.a')); + expect(stored.globalSequence, equals(42)); + }); + + test('constructor accepts explicit domainEvent', () { + final domainEvent = _TestEventA(eventId: EventId.fromUlid()); + + final stored = StoredEvent( + eventId: domainEvent.id, + streamId: const StreamId('s1'), + version: 0, + eventType: 'test.a', + data: const {'k': 'v'}, + occurredOn: domainEvent.occurredOn, + metadata: domainEvent.metadata, + domainEvent: domainEvent, + ); + + expect(stored.domainEvent, same(domainEvent)); + }); + }); +} + +final class _TestEventA implements ContinuumEvent { + _TestEventA({ + required EventId eventId, + DateTime? occurredOn, + Map metadata = const {}, + }) : id = eventId, + occurredOn = occurredOn ?? DateTime.now(), + metadata = Map.unmodifiable(metadata); + + @override + final EventId id; + + @override + final DateTime occurredOn; + + @override + final Map metadata; +} diff --git a/packages/continuum/test/projections/async_projection_executor_test.dart b/packages/continuum/test/projections/async_projection_executor_test.dart index b5f43e2..9459db7 100644 --- a/packages/continuum/test/projections/async_projection_executor_test.dart +++ b/packages/continuum/test/projections/async_projection_executor_test.dart @@ -97,7 +97,7 @@ void main() { await executor.processEventsAsync(events); final position = await positionStore.loadPositionAsync('counter'); - expect(position, equals(12)); + expect(position?.lastProcessedSequence, equals(12)); }); test('processEventsAsync skips inline projections', () async { @@ -177,7 +177,7 @@ void main() { await executor.processEventsAsync([event]); final position = await executor.getPositionAsync('counter'); - expect(position, equals(42)); + expect(position?.lastProcessedSequence, equals(42)); }); test('getPositionAsync returns null for unprocessed projection', () async { @@ -191,7 +191,10 @@ void main() { }); test('resetPositionAsync clears projection position', () async { - await positionStore.savePositionAsync('counter', 100); + await positionStore.savePositionAsync( + 'counter', + const ProjectionPosition(lastProcessedSequence: 100, schemaHash: 'test'), + ); executor = AsyncProjectionExecutor( registry: registry, positionStore: positionStore, diff --git a/packages/continuum/test/projections/generated_projection_dispatch_test.dart b/packages/continuum/test/projections/generated_projection_dispatch_test.dart new file mode 100644 index 0000000..61ff377 --- /dev/null +++ b/packages/continuum/test/projections/generated_projection_dispatch_test.dart @@ -0,0 +1,134 @@ +import 'package:continuum/continuum.dart'; +import 'package:test/test.dart'; +import 'package:zooper_flutter_core/zooper_flutter_core.dart'; + +void main() { + group('Generated projection dispatch (domainEvent)', () { + test('apply dispatches based on StoredEvent.domainEvent runtime type', () { + final projection = _GeneratedStyleProjection(); + + final eventA = _TestEventA(eventId: EventId.fromUlid()); + final storedA = StoredEvent.fromContinuumEvent( + continuumEvent: eventA, + streamId: const StreamId('s1'), + version: 0, + eventType: 'test.a', + data: const {'a': 1}, + ); + + final resultA = projection.apply(0, storedA); + expect(resultA, equals(1)); + + final eventB = _TestEventB(eventId: EventId.fromUlid()); + final storedB = StoredEvent.fromContinuumEvent( + continuumEvent: eventB, + streamId: const StreamId('s1'), + version: 1, + eventType: 'test.b', + data: const {'b': 1}, + ); + + final resultB = projection.apply(resultA, storedB); + expect(resultB, equals(11)); + }); + + test('apply throws StateError when StoredEvent.domainEvent is null', () { + final projection = _GeneratedStyleProjection(); + + final stored = StoredEvent( + eventId: EventId.fromUlid(), + streamId: const StreamId('s1'), + version: 0, + eventType: 'test.a', + data: const {'a': 1}, + occurredOn: DateTime.now(), + metadata: const {}, + domainEvent: null, + ); + + expect( + () => projection.apply(0, stored), + throwsA(isA()), + ); + }); + }); +} + +final class _GeneratedStyleProjection extends SingleStreamProjection with _$_GeneratedStyleProjectionHandlers { + @override + int createInitial(StreamId streamId) => 0; + + @override + int applyTestEventA(int current, _TestEventA event) => current + 1; + + @override + int applyTestEventB(int current, _TestEventB event) => current + 10; +} + +// A minimal stand-in for generated code. +mixin _$_GeneratedStyleProjectionHandlers { + Set get handledEventTypes => const {_TestEventA, _TestEventB}; + + String get projectionName => 'generated-style'; + + int apply(int current, StoredEvent event) { + final domainEvent = event.domainEvent; + if (domainEvent == null) { + throw StateError( + 'StoredEvent.domainEvent is null. ' + 'Projections require deserialized domain events.', + ); + } + + return switch (domainEvent) { + _TestEventA() => applyTestEventA(current, domainEvent), + _TestEventB() => applyTestEventB(current, domainEvent), + _ => throw UnsupportedEventException( + eventType: domainEvent.runtimeType, + projectionType: _GeneratedStyleProjection, + ), + }; + } + + int applyTestEventA(int current, _TestEventA event); + + int applyTestEventB(int current, _TestEventB event); +} + +final class _TestEventA implements ContinuumEvent { + _TestEventA({ + required EventId eventId, + DateTime? occurredOn, + Map metadata = const {}, + }) : id = eventId, + occurredOn = occurredOn ?? DateTime.now(), + metadata = Map.unmodifiable(metadata); + + @override + final EventId id; + + @override + final DateTime occurredOn; + + @override + final Map metadata; +} + +final class _TestEventB implements ContinuumEvent { + _TestEventB({ + required EventId eventId, + DateTime? occurredOn, + Map metadata = const {}, + }) : id = eventId, + occurredOn = occurredOn ?? DateTime.now(), + metadata = Map.unmodifiable(metadata); + + @override + final EventId id; + + @override + final DateTime occurredOn; + + @override + final Map metadata; +} diff --git a/packages/continuum/test/projections/projection_position_store_test.dart b/packages/continuum/test/projections/projection_position_store_test.dart index 0f8d662..9762e2b 100644 --- a/packages/continuum/test/projections/projection_position_store_test.dart +++ b/packages/continuum/test/projections/projection_position_store_test.dart @@ -1,3 +1,4 @@ +import 'package:continuum/src/projections/projection_position.dart'; import 'package:continuum/src/projections/projection_position_store.dart'; import 'package:test/test.dart'; @@ -16,49 +17,88 @@ void main() { }); test('savePositionAsync stores position', () async { - await store.savePositionAsync('projection-1', 42); + await store.savePositionAsync( + 'projection-1', + const ProjectionPosition(lastProcessedSequence: 42, schemaHash: 'abc'), + ); final position = await store.loadPositionAsync('projection-1'); - expect(position, equals(42)); + expect(position?.lastProcessedSequence, equals(42)); + expect(position?.schemaHash, equals('abc')); }); test('savePositionAsync overwrites existing position', () async { - await store.savePositionAsync('projection-1', 10); - await store.savePositionAsync('projection-1', 50); + await store.savePositionAsync( + 'projection-1', + const ProjectionPosition(lastProcessedSequence: 10, schemaHash: 'v1'), + ); + await store.savePositionAsync( + 'projection-1', + const ProjectionPosition(lastProcessedSequence: 50, schemaHash: 'v2'), + ); final position = await store.loadPositionAsync('projection-1'); - expect(position, equals(50)); + expect(position?.lastProcessedSequence, equals(50)); + expect(position?.schemaHash, equals('v2')); }); test('stores positions for multiple projections independently', () async { - await store.savePositionAsync('projection-a', 100); - await store.savePositionAsync('projection-b', 200); - await store.savePositionAsync('projection-c', 300); - - expect(await store.loadPositionAsync('projection-a'), equals(100)); - expect(await store.loadPositionAsync('projection-b'), equals(200)); - expect(await store.loadPositionAsync('projection-c'), equals(300)); + await store.savePositionAsync( + 'projection-a', + const ProjectionPosition(lastProcessedSequence: 100, schemaHash: 'a'), + ); + await store.savePositionAsync( + 'projection-b', + const ProjectionPosition(lastProcessedSequence: 200, schemaHash: 'b'), + ); + await store.savePositionAsync( + 'projection-c', + const ProjectionPosition(lastProcessedSequence: 300, schemaHash: 'c'), + ); + + final posA = await store.loadPositionAsync('projection-a'); + final posB = await store.loadPositionAsync('projection-b'); + final posC = await store.loadPositionAsync('projection-c'); + + expect(posA?.lastProcessedSequence, equals(100)); + expect(posB?.lastProcessedSequence, equals(200)); + expect(posC?.lastProcessedSequence, equals(300)); }); test('length returns number of tracked projections', () async { expect(store.length, equals(0)); - await store.savePositionAsync('p1', 1); + await store.savePositionAsync( + 'p1', + const ProjectionPosition(lastProcessedSequence: 1, schemaHash: 'h1'), + ); expect(store.length, equals(1)); - await store.savePositionAsync('p2', 2); + await store.savePositionAsync( + 'p2', + const ProjectionPosition(lastProcessedSequence: 2, schemaHash: 'h2'), + ); expect(store.length, equals(2)); // Overwrite doesn't increase length - await store.savePositionAsync('p1', 10); + await store.savePositionAsync( + 'p1', + const ProjectionPosition(lastProcessedSequence: 10, schemaHash: 'h1'), + ); expect(store.length, equals(2)); }); test('clear removes all positions', () async { - await store.savePositionAsync('p1', 1); - await store.savePositionAsync('p2', 2); + await store.savePositionAsync( + 'p1', + const ProjectionPosition(lastProcessedSequence: 1, schemaHash: 'h'), + ); + await store.savePositionAsync( + 'p2', + const ProjectionPosition(lastProcessedSequence: 2, schemaHash: 'h'), + ); store.clear(); @@ -68,31 +108,67 @@ void main() { }); test('remove removes specific projection position', () async { - await store.savePositionAsync('p1', 1); - await store.savePositionAsync('p2', 2); + await store.savePositionAsync( + 'p1', + const ProjectionPosition(lastProcessedSequence: 1, schemaHash: 'h'), + ); + await store.savePositionAsync( + 'p2', + const ProjectionPosition(lastProcessedSequence: 2, schemaHash: 'h'), + ); store.remove('p1'); expect(await store.loadPositionAsync('p1'), isNull); - expect(await store.loadPositionAsync('p2'), equals(2)); + expect((await store.loadPositionAsync('p2'))?.lastProcessedSequence, equals(2)); expect(store.length, equals(1)); }); test('handles position value of zero', () async { - await store.savePositionAsync('projection-1', 0); + await store.savePositionAsync( + 'projection-1', + const ProjectionPosition(lastProcessedSequence: 0, schemaHash: 'h'), + ); final position = await store.loadPositionAsync('projection-1'); - expect(position, equals(0)); + expect(position?.lastProcessedSequence, equals(0)); }); test('handles large position values', () async { const largePosition = 9223372036854775807; // Max int64 - await store.savePositionAsync('projection-1', largePosition); + await store.savePositionAsync( + 'projection-1', + const ProjectionPosition(lastProcessedSequence: largePosition, schemaHash: 'h'), + ); + + final position = await store.loadPositionAsync('projection-1'); + + expect(position?.lastProcessedSequence, equals(largePosition)); + }); + + test('resetPositionAsync removes projection position', () async { + await store.savePositionAsync( + 'projection-1', + const ProjectionPosition(lastProcessedSequence: 42, schemaHash: 'h'), + ); + + await store.resetPositionAsync('projection-1'); + + expect(await store.loadPositionAsync('projection-1'), isNull); + }); + + test('schema hash tracking works correctly', () async { + await store.savePositionAsync( + 'projection-1', + const ProjectionPosition(lastProcessedSequence: 10, schemaHash: 'schema-v1'), + ); final position = await store.loadPositionAsync('projection-1'); - expect(position, equals(largePosition)); + expect(position?.schemaHash, equals('schema-v1')); + expect(position?.hasSchemaChangedFrom('schema-v1'), isFalse); + expect(position?.hasSchemaChangedFrom('schema-v2'), isTrue); }); }); } diff --git a/packages/continuum/test/projections/projection_processor_test.dart b/packages/continuum/test/projections/projection_processor_test.dart index 7eff35e..a6089b2 100644 --- a/packages/continuum/test/projections/projection_processor_test.dart +++ b/packages/continuum/test/projections/projection_processor_test.dart @@ -67,7 +67,7 @@ void main() { await processor.processBatchAsync(); final position = await positionStore.loadPositionAsync('_processor_position'); - expect(position, equals(11)); + expect(position?.lastProcessedSequence, equals(11)); }); test('processBatchAsync resumes from last position', () async { @@ -79,7 +79,10 @@ void main() { ]; // Set position to 1, so we should start from 2. - await positionStore.savePositionAsync('_processor_position', 1); + await positionStore.savePositionAsync( + '_processor_position', + const ProjectionPosition(lastProcessedSequence: 1, schemaHash: 'test'), + ); processor = createProcessor(); final result = await processor.processBatchAsync(); @@ -111,7 +114,7 @@ void main() { expect(result.processed, equals(3)); final position = await positionStore.loadPositionAsync('_processor_position'); - expect(position, equals(2)); // Last processed was index 2. + expect(position?.lastProcessedSequence, equals(2)); // Last processed was index 2. }); test('startAsync and stopAsync control processor lifecycle', () async { diff --git a/packages/continuum/test/projections/projection_registry_test.dart b/packages/continuum/test/projections/projection_registry_test.dart index a2e13b3..5f5be1a 100644 --- a/packages/continuum/test/projections/projection_registry_test.dart +++ b/packages/continuum/test/projections/projection_registry_test.dart @@ -171,6 +171,76 @@ void main() { expect(registry.isEmpty, isFalse); expect(registry.isNotEmpty, isTrue); }); + + group('Generated projection support', () { + test('registerGeneratedInline registers with bundle metadata', () { + const bundle = GeneratedProjection( + projectionName: 'gen-inline', + schemaHash: 'abc123', + handledEventTypes: {_EventA, _EventB}, + ); + final projection = _CounterProjection('gen-inline', {_EventA, _EventB}); + final store = InMemoryReadModelStore(); + + registry.registerGeneratedInline(bundle, projection, store); + + expect(registry.length, equals(1)); + expect(registry.hasInlineProjections, isTrue); + expect(registry.getSchemaHash('gen-inline'), equals('abc123')); + }); + + test('registerGeneratedAsync registers with bundle metadata', () { + const bundle = GeneratedProjection( + projectionName: 'gen-async', + schemaHash: 'def456', + handledEventTypes: {_EventA}, + ); + final projection = _CounterProjection('gen-async', {_EventA}); + final store = InMemoryReadModelStore(); + + registry.registerGeneratedAsync(bundle, projection, store); + + expect(registry.length, equals(1)); + expect(registry.hasAsyncProjections, isTrue); + expect(registry.getSchemaHash('gen-async'), equals('def456')); + }); + + test('getSchemaHash returns empty string for manual registrations', () { + final projection = _CounterProjection('manual'); + final store = InMemoryReadModelStore(); + + registry.registerInline(projection, store); + + expect(registry.getSchemaHash('manual'), isEmpty); + }); + + test('getSchemaHash returns empty string for unknown projection', () { + expect(registry.getSchemaHash('unknown'), isEmpty); + }); + + test('registerGeneratedInline throws on duplicate name', () { + const bundle1 = GeneratedProjection( + projectionName: 'duplicate', + schemaHash: 'hash1', + handledEventTypes: {_EventA}, + ); + const bundle2 = GeneratedProjection( + projectionName: 'duplicate', + schemaHash: 'hash2', + handledEventTypes: {_EventB}, + ); + final projection1 = _CounterProjection('duplicate', {_EventA}); + final projection2 = _CounterProjection('duplicate', {_EventB}); + final store = InMemoryReadModelStore(); + + registry.registerGeneratedInline(bundle1, projection1, store); + + expect( + () => registry.registerGeneratedInline(bundle2, projection2, store), + throwsStateError, + ); + }); + }); }); group('ProjectionLifecycle', () { diff --git a/packages/continuum_generator/lib/src/combining_builder.dart b/packages/continuum_generator/lib/src/combining_builder.dart index 7d7486a..bebdcfb 100644 --- a/packages/continuum_generator/lib/src/combining_builder.dart +++ b/packages/continuum_generator/lib/src/combining_builder.dart @@ -10,11 +10,11 @@ const List _generatedDartFileSuffixesToIgnore = [ '.mocks.dart', ]; -/// A builder that combines all discovered aggregates into a single file. +/// A builder that combines all discovered aggregates and projections into a single file. /// -/// This builder runs after all per-aggregate generators have completed. -/// It scans the entire package for `@Aggregate()` annotations and generates -/// a single `lib/continuum.g.dart` file containing `$aggregateList`. +/// This builder runs after all per-aggregate and per-projection generators have completed. +/// It scans the entire package for `@Aggregate()` and `@Projection()` annotations and generates +/// a single `lib/continuum.g.dart` file containing `$aggregateList` and `$projectionList`. /// /// Users can then simply write: /// ```dart @@ -24,11 +24,18 @@ const List _generatedDartFileSuffixesToIgnore = [ /// eventStore: InMemoryEventStore(), /// aggregates: $aggregateList, /// ); +/// +/// for (final projection in $projectionList) { +/// registry.registerGeneratedInline(projection, ...); +/// } /// ``` class CombiningBuilder implements Builder { /// Type checker for the @Aggregate annotation. static const _aggregateChecker = TypeChecker.fromUrl('package:continuum/src/annotations/aggregate.dart#Aggregate'); + /// Type checker for the @Projection annotation. + static const _projectionChecker = TypeChecker.fromUrl('package:continuum/src/annotations/projection.dart#Projection'); + @override Map> get buildExtensions => { r'$lib$': ['continuum.g.dart'], @@ -38,7 +45,8 @@ class CombiningBuilder implements Builder { Future build(BuildStep buildStep) async { // Find all Dart files in lib/ final dartFiles = Glob('lib/**.dart'); - final aggregateInfos = <_AggregateInfo>[]; + final aggregateInfos = <_DiscoveredInfo>[]; + final projectionInfos = <_DiscoveredInfo>[]; await for (final input in buildStep.findAssets(dartFiles)) { // Skip generated files to avoid cycles and non-library `part of` files. @@ -48,17 +56,29 @@ class CombiningBuilder implements Builder { // Those are not libraries, and `libraryFor(...)` will throw. if (!await buildStep.resolver.isLibrary(input)) continue; - // Try to resolve the library + // Try to resolve the library. final library = await buildStep.resolver.libraryFor(input); - // Find all classes annotated with @Aggregate + // Calculate the import path relative to lib/. + final importPath = input.path.replaceFirst('lib/', ''); + + // Find all classes annotated with @Aggregate. for (final element in library.classes) { if (_aggregateChecker.hasAnnotationOf(element)) { - // Calculate the import path relative to lib/ - final importPath = input.path.replaceFirst('lib/', ''); - aggregateInfos.add( - _AggregateInfo( + _DiscoveredInfo( + className: element.displayName, + importPath: importPath, + ), + ); + } + } + + // Find all classes annotated with @Projection. + for (final element in library.classes) { + if (_projectionChecker.hasAnnotationOf(element)) { + projectionInfos.add( + _DiscoveredInfo( className: element.displayName, importPath: importPath, ), @@ -67,13 +87,20 @@ class CombiningBuilder implements Builder { } } - // Skip if no aggregates found - if (aggregateInfos.isEmpty) return; + // Skip if neither aggregates nor projections found. + if (aggregateInfos.isEmpty && projectionInfos.isEmpty) return; - // Sort for deterministic output + // Sort for deterministic output. aggregateInfos.sort((a, b) => a.className.compareTo(b.className)); + projectionInfos.sort((a, b) => a.className.compareTo(b.className)); + + // Collect unique import paths. + final allImportPaths = { + ...aggregateInfos.map((i) => i.importPath), + ...projectionInfos.map((i) => i.importPath), + }.toList()..sort(); - // Generate the combining file + // Generate the combining file. final buffer = StringBuffer(); buffer.writeln('// GENERATED CODE - DO NOT MODIFY BY HAND'); @@ -83,32 +110,57 @@ class CombiningBuilder implements Builder { buffer.writeln("import 'package:continuum/continuum.dart';"); buffer.writeln(); - // Generate imports for each aggregate file - for (final info in aggregateInfos) { - buffer.writeln("import '${info.importPath}';"); + // Generate imports for each discovered file. + for (final importPath in allImportPaths) { + buffer.writeln("import '$importPath';"); } buffer.writeln(); - buffer.writeln('/// All discovered aggregates in this package.'); - buffer.writeln('///'); - buffer.writeln('/// Pass this list to [EventSourcingStore] for automatic'); - buffer.writeln('/// registration of all serializers, factories, and appliers.'); - buffer.writeln('///'); - buffer.writeln('/// ```dart'); - buffer.writeln('/// final store = EventSourcingStore('); - buffer.writeln('/// eventStore: InMemoryEventStore(),'); - buffer.writeln('/// aggregates: \$aggregateList,'); - buffer.writeln('/// );'); - buffer.writeln('/// ```'); - buffer.writeln('final List \$aggregateList = ['); - - for (final info in aggregateInfos) { - buffer.writeln(' \$${info.className},'); + + // Generate $aggregateList if any aggregates found. + if (aggregateInfos.isNotEmpty) { + buffer.writeln('/// All discovered aggregates in this package.'); + buffer.writeln('///'); + buffer.writeln('/// Pass this list to [EventSourcingStore] for automatic'); + buffer.writeln('/// registration of all serializers, factories, and appliers.'); + buffer.writeln('///'); + buffer.writeln('/// ```dart'); + buffer.writeln('/// final store = EventSourcingStore('); + buffer.writeln('/// eventStore: InMemoryEventStore(),'); + buffer.writeln('/// aggregates: \$aggregateList,'); + buffer.writeln('/// );'); + buffer.writeln('/// ```'); + buffer.writeln('final List \$aggregateList = ['); + + for (final info in aggregateInfos) { + buffer.writeln(' \$${info.className},'); + } + + buffer.writeln('];'); + buffer.writeln(); } - buffer.writeln('];'); + // Generate $projectionList if any projections found. + if (projectionInfos.isNotEmpty) { + buffer.writeln('/// All discovered projections in this package.'); + buffer.writeln('///'); + buffer.writeln('/// Use this list to register all projections with the registry.'); + buffer.writeln('///'); + buffer.writeln('/// ```dart'); + buffer.writeln('/// for (final bundle in \$projectionList) {'); + buffer.writeln('/// // Register with appropriate lifecycle and store'); + buffer.writeln('/// }'); + buffer.writeln('/// ```'); + buffer.writeln('final List \$projectionList = ['); + + for (final info in projectionInfos) { + buffer.writeln(' \$${info.className},'); + } - // Write the output + buffer.writeln('];'); + } + + // Write the output. final outputId = AssetId( buildStep.inputId.package, 'lib/continuum.g.dart', @@ -118,12 +170,16 @@ class CombiningBuilder implements Builder { } } -/// Internal info about a discovered aggregate. -class _AggregateInfo { +/// Internal info about a discovered aggregate or projection. +class _DiscoveredInfo { + /// The class name. final String className; + + /// The import path relative to lib/. final String importPath; - _AggregateInfo({ + /// Creates discovered info. + _DiscoveredInfo({ required this.className, required this.importPath, }); diff --git a/packages/continuum_generator/lib/src/continuum_generator.dart b/packages/continuum_generator/lib/src/continuum_generator.dart index ddfe352..226c13f 100644 --- a/packages/continuum_generator/lib/src/continuum_generator.dart +++ b/packages/continuum_generator/lib/src/continuum_generator.dart @@ -7,14 +7,18 @@ import 'package:source_gen/source_gen.dart'; import 'aggregate_discovery.dart'; import 'code_emitter.dart'; +import 'projection_code_emitter.dart'; +import 'projection_discovery.dart'; /// Generator for continuum event sourcing code. /// -/// Scans for @Aggregate and @Event annotations and generates: +/// Scans for @Aggregate, @AggregateEvent, and @Projection annotations and generates: /// - Event handler mixins for mutation events /// - Apply dispatchers and replay helpers /// - Creation dispatchers for aggregate instantiation /// - Event registry for persistence deserialization +/// - Projection handler mixins and dispatchers +/// - Projection bundles for registry configuration class ContinuumGenerator extends Generator { static const List _generatedDartFileSuffixesToIgnore = [ '.freezed.dart', @@ -22,37 +26,52 @@ class ContinuumGenerator extends Generator { '.mocks.dart', ]; - final _discovery = AggregateDiscovery(); - final _emitter = CodeEmitter(); + final _aggregateDiscovery = AggregateDiscovery(); + final _aggregateEmitter = CodeEmitter(); + final _projectionDiscovery = ProjectionDiscovery(); + final _projectionEmitter = ProjectionCodeEmitter(); @override FutureOr generate(LibraryReader library, BuildStep buildStep) async { - // First: discover whether this library defines any aggregates. - final localAggregates = _discovery.discoverAggregates(library.element); + // Discover aggregates and projections in this library. + final localAggregates = _aggregateDiscovery.discoverAggregates(library.element); + final projections = _projectionDiscovery.discoverProjections(library.element); - // Skip if no aggregates found - if (localAggregates.isEmpty) { + // Skip if neither aggregates nor projections found. + if (localAggregates.isEmpty && projections.isEmpty) { return null; } - // Then: collect all libraries in the current package so we can discover - // events annotated as belonging to these aggregates, even when the aggregate - // library doesn't import the event library. - final candidateEventLibraries = await _collectPackageLibrariesAsync(buildStep); + final outputBuffer = StringBuffer(); - // Re-run discovery with the wider candidate set. - final aggregates = _discovery.discoverAggregates( - library.element, - candidateEventLibraries: candidateEventLibraries, - ); + // Generate aggregate code if any aggregates found. + if (localAggregates.isNotEmpty) { + // Collect all libraries in the current package so we can discover + // events annotated as belonging to these aggregates, even when the aggregate + // library doesn't import the event library. + final candidateEventLibraries = await _collectPackageLibrariesAsync(buildStep); - // Generate code for all aggregates - final output = _emitter.emit(aggregates); + // Re-run discovery with the wider candidate set. + final aggregates = _aggregateDiscovery.discoverAggregates( + library.element, + candidateEventLibraries: candidateEventLibraries, + ); + + // Generate code for all aggregates. + final aggregateOutput = _aggregateEmitter.emit(aggregates); + outputBuffer.writeln(aggregateOutput); + } + + // Generate projection code if any projections found. + if (projections.isNotEmpty) { + final projectionOutput = _projectionEmitter.emit(projections); + outputBuffer.writeln(projectionOutput); + } // Return the generated code directly. // Note: Part files inherit imports from the main library file, // so no explicit imports are needed here. - return output; + return outputBuffer.toString(); } /// Collects all resolvable Dart libraries in the current package. diff --git a/packages/continuum_generator/lib/src/models/projection_info.dart b/packages/continuum_generator/lib/src/models/projection_info.dart new file mode 100644 index 0000000..ca7b429 --- /dev/null +++ b/packages/continuum_generator/lib/src/models/projection_info.dart @@ -0,0 +1,49 @@ +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/type.dart'; + +/// Represents a projection discovered during code generation. +/// +/// Contains information about the projection class, its name, +/// handled event types, and the read model type. +final class ProjectionInfo { + /// The class element representing the projection. + final ClassElement element; + + /// The unique name for this projection (from annotation). + final String projectionName; + + /// The event types this projection handles (from annotation). + final List eventTypes; + + /// The read model type inferred from the base class generic parameter. + /// + /// For `SingleStreamProjection`, this would be `UserProfile`. + final DartType? readModelType; + + /// The key type inferred from the base class generic parameter. + /// + /// For `SingleStreamProjection`, this is `StreamId`. + /// For `MultiStreamProjection`, this is `K`. + final DartType? keyType; + + /// Creates a projection info with the given properties. + ProjectionInfo({ + required this.element, + required this.projectionName, + required this.eventTypes, + this.readModelType, + this.keyType, + }); + + /// The name of the projection class. + String get className => element.name ?? element.displayName; + + /// Returns the event type names as strings for code generation. + List get eventTypeNames => eventTypes.map((type) => type.element?.name ?? type.toString()).toList(); + + /// Returns the read model type name as a string for code generation. + String get readModelTypeName => readModelType?.getDisplayString() ?? 'dynamic'; + + /// Returns the key type name as a string for code generation. + String get keyTypeName => keyType?.getDisplayString() ?? 'dynamic'; +} diff --git a/packages/continuum_generator/lib/src/projection_code_emitter.dart b/packages/continuum_generator/lib/src/projection_code_emitter.dart new file mode 100644 index 0000000..ece1a22 --- /dev/null +++ b/packages/continuum_generator/lib/src/projection_code_emitter.dart @@ -0,0 +1,179 @@ +import 'models/projection_info.dart'; + +/// Generates code for projections. +/// +/// Creates event handler mixins, apply dispatchers, and projection bundles +/// for classes annotated with `@Projection`. +final class ProjectionCodeEmitter { + /// Generates all code for the given projections. + /// + /// Returns the combined generated code as a string. + String emit(List projections) { + final buffer = StringBuffer(); + + for (final projection in projections) { + buffer.writeln(_emitHandlersMixin(projection)); + buffer.writeln(); + buffer.writeln(_emitDispatchExtension(projection)); + buffer.writeln(); + buffer.writeln(_emitProjectionBundle(projection)); + buffer.writeln(); + } + + return buffer.toString(); + } + + /// Generates the event handlers mixin. + /// + /// This mixin provides: + /// - `handledEventTypes` getter (overrides base class) + /// - `projectionName` getter (overrides base class) + /// - `apply()` method that dispatches to typed handlers (overrides base class) + /// - Abstract `apply()` methods for each event type + String _emitHandlersMixin(ProjectionInfo projection) { + final buffer = StringBuffer(); + final className = projection.className; + final readModelType = projection.readModelTypeName; + final projectionName = projection.projectionName; + + buffer.writeln('/// Generated mixin providing event handling for $className.'); + buffer.writeln('///'); + buffer.writeln('/// This mixin provides the [handledEventTypes], [projectionName], and [apply]'); + buffer.writeln('/// implementations. Implement the abstract `apply` methods.'); + buffer.writeln('mixin _\$${className}Handlers {'); + + // Generate handledEventTypes getter. + buffer.writeln(' /// The set of event types this projection handles.'); + buffer.writeln(' Set get handledEventTypes => const {'); + for (final eventTypeName in projection.eventTypeNames) { + buffer.writeln(' $eventTypeName,'); + } + buffer.writeln(' };'); + buffer.writeln(); + + // Generate projectionName getter. + buffer.writeln(' /// The unique name identifying this projection.'); + buffer.writeln(" String get projectionName => '$projectionName';"); + buffer.writeln(); + + // Generate apply method that dispatches to typed handlers. + buffer.writeln(' /// Applies an event to update the read model.'); + buffer.writeln(' ///'); + buffer.writeln(' /// Routes the event to the appropriate typed handler method.'); + buffer.writeln(' /// Throws [UnsupportedEventException] for unknown event types.'); + buffer.writeln(' $readModelType apply($readModelType current, StoredEvent event) {'); + buffer.writeln(' final domainEvent = event.domainEvent;'); + buffer.writeln(' if (domainEvent == null) {'); + buffer.writeln(' throw StateError('); + buffer.writeln(" 'StoredEvent.domainEvent is null. '"); + buffer.writeln(" 'Projections require deserialized domain events.',"); + buffer.writeln(' );'); + buffer.writeln(' }'); + buffer.writeln(' return switch (domainEvent) {'); + for (final eventTypeName in projection.eventTypeNames) { + buffer.writeln(' $eventTypeName() => apply$eventTypeName(current, domainEvent),'); + } + buffer.writeln(' _ => throw UnsupportedEventException('); + buffer.writeln(' eventType: domainEvent.runtimeType,'); + buffer.writeln(' projectionType: $className,'); + buffer.writeln(' ),'); + buffer.writeln(' };'); + buffer.writeln(' }'); + buffer.writeln(); + + // Generate abstract apply methods for each event type. + for (final eventTypeName in projection.eventTypeNames) { + buffer.writeln(' /// Applies a $eventTypeName event to the read model.'); + buffer.writeln(' $readModelType apply$eventTypeName($readModelType current, $eventTypeName event);'); + buffer.writeln(); + } + + buffer.writeln('}'); + + return buffer.toString(); + } + + /// Generates the dispatch extension for event routing. + /// + /// Provides `applyEvent()` for convenient routing from domain events. + String _emitDispatchExtension(ProjectionInfo projection) { + final buffer = StringBuffer(); + final className = projection.className; + final readModelType = projection.readModelTypeName; + + buffer.writeln('/// Generated extension providing additional event dispatch for $className.'); + buffer.writeln('extension \$${className}EventDispatch on $className {'); + + // Generate applyEvent dispatcher for ContinuumEvent (convenience method). + buffer.writeln(' /// Routes a domain event to the appropriate apply method.'); + buffer.writeln(' ///'); + buffer.writeln(' /// This is a convenience method for applying events directly without'); + buffer.writeln(' /// wrapping in [StoredEvent]. For normal projection processing, use [apply].'); + buffer.writeln(' ///'); + buffer.writeln(' /// Throws [UnsupportedEventException] for unknown event types.'); + buffer.writeln(' $readModelType applyEvent($readModelType current, ContinuumEvent event) {'); + buffer.writeln(' return switch (event) {'); + + for (final eventTypeName in projection.eventTypeNames) { + buffer.writeln(' $eventTypeName() => apply$eventTypeName(current, event),'); + } + + buffer.writeln(' _ => throw UnsupportedEventException('); + buffer.writeln(' eventType: event.runtimeType,'); + buffer.writeln(' projectionType: $className,'); + buffer.writeln(' ),'); + buffer.writeln(' };'); + buffer.writeln(' }'); + + buffer.writeln('}'); + + return buffer.toString(); + } + + /// Generates the projection bundle constant. + String _emitProjectionBundle(ProjectionInfo projection) { + final buffer = StringBuffer(); + final className = projection.className; + final projectionName = projection.projectionName; + + // Compute schema hash from sorted event type names. + final schemaHash = _computeSchemaHash(projection.eventTypeNames); + + buffer.writeln('/// Generated projection bundle for $className.'); + buffer.writeln('///'); + buffer.writeln('/// Contains metadata for registry configuration.'); + buffer.writeln('/// Add to the `projections` list when creating a [ProjectionRegistry].'); + buffer.writeln('final \$$className = GeneratedProjection('); + buffer.writeln(" projectionName: '$projectionName',"); + buffer.writeln(" schemaHash: '$schemaHash',"); + buffer.writeln(' handledEventTypes: {'); + for (final eventTypeName in projection.eventTypeNames) { + buffer.writeln(' $eventTypeName,'); + } + buffer.writeln(' },'); + buffer.writeln(');'); + + return buffer.toString(); + } + + /// Computes a schema hash from the sorted event type names. + /// + /// This hash changes when events are added, removed, or renamed, + /// triggering a projection rebuild. + /// + /// Uses a simple hash algorithm to avoid external dependencies. + String _computeSchemaHash(List eventTypeNames) { + final sorted = List.from(eventTypeNames)..sort(); + final concatenated = sorted.join(','); + + // Simple FNV-1a hash (32-bit) for deterministic, fast hashing. + var hash = 0x811c9dc5; + for (var i = 0; i < concatenated.length; i++) { + hash ^= concatenated.codeUnitAt(i); + hash = (hash * 0x01000193) & 0xFFFFFFFF; + } + + // Return as 8 hex characters. + return hash.toRadixString(16).padLeft(8, '0'); + } +} diff --git a/packages/continuum_generator/lib/src/projection_discovery.dart b/packages/continuum_generator/lib/src/projection_discovery.dart new file mode 100644 index 0000000..571733e --- /dev/null +++ b/packages/continuum_generator/lib/src/projection_discovery.dart @@ -0,0 +1,134 @@ +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/type.dart'; +import 'package:source_gen/source_gen.dart'; + +import 'models/projection_info.dart'; + +/// Type checker for the @Projection annotation. +const _projectionChecker = TypeChecker.fromUrl( + 'package:continuum/src/annotations/projection.dart#Projection', +); + +/// Type checker for SingleStreamProjection base class. +const _singleStreamProjectionChecker = TypeChecker.fromUrl( + 'package:continuum/src/projections/single_stream_projection.dart#SingleStreamProjection', +); + +/// Type checker for MultiStreamProjection base class. +const _multiStreamProjectionChecker = TypeChecker.fromUrl( + 'package:continuum/src/projections/multi_stream_projection.dart#MultiStreamProjection', +); + +/// Discovers projections from library elements. +/// +/// Scans a library for classes annotated with `@Projection()` and extracts +/// the projection name, handled event types, and read model type. +final class ProjectionDiscovery { + /// Discovers all projections in the given library. + /// + /// Returns a list of [ProjectionInfo] with projection metadata. + List discoverProjections(LibraryElement library) { + final projections = []; + + for (final element in library.classes) { + // Skip classes without @Projection annotation. + if (!_projectionChecker.hasAnnotationOf(element)) continue; + + final annotation = _projectionChecker.firstAnnotationOf(element); + if (annotation == null) continue; + + // Extract projection name from annotation. + final nameValue = annotation.getField('name'); + final projectionName = nameValue?.toStringValue(); + if (projectionName == null || projectionName.isEmpty) { + throw InvalidGenerationSourceError( + '@Projection annotation requires a non-empty "name" parameter.', + element: element, + ); + } + + // Extract events list from annotation. + final eventsField = annotation.getField('events'); + final eventTypes = []; + if (eventsField != null && !eventsField.isNull) { + final eventsList = eventsField.toListValue(); + if (eventsList != null) { + for (final eventValue in eventsList) { + final eventType = eventValue.toTypeValue(); + if (eventType != null) { + eventTypes.add(eventType); + } + } + } + } + + if (eventTypes.isEmpty) { + throw InvalidGenerationSourceError( + '@Projection annotation requires a non-empty "events" list.', + element: element, + ); + } + + // Infer read model type and key type from base class. + final (readModelType, keyType) = _inferTypesFromBaseClass(element); + + projections.add( + ProjectionInfo( + element: element, + projectionName: projectionName, + eventTypes: eventTypes, + readModelType: readModelType, + keyType: keyType, + ), + ); + } + + return projections; + } + + /// Infers the read model type and key type from the projection's base class. + /// + /// - `SingleStreamProjection` → readModel: T, key: StreamId + /// - `MultiStreamProjection` → readModel: T, key: K + (DartType?, DartType?) _inferTypesFromBaseClass(ClassElement element) { + // Check all supertypes to find the projection base class. + for (final supertype in element.allSupertypes) { + final superElement = supertype.element; + + // Check for SingleStreamProjection. + if (_singleStreamProjectionChecker.isExactlyType(supertype)) { + final typeArgs = supertype.typeArguments; + if (typeArgs.isNotEmpty) { + // SingleStreamProjection has StreamId as key type (built-in). + return (typeArgs[0], null); + } + } + + // Check for MultiStreamProjection. + if (_multiStreamProjectionChecker.isExactlyType(supertype)) { + final typeArgs = supertype.typeArguments; + if (typeArgs.length >= 2) { + return (typeArgs[0], typeArgs[1]); + } + } + + // Also check using assignable check for extended classes. + if (superElement is ClassElement) { + if (_singleStreamProjectionChecker.isAssignableFromType(supertype) && !_multiStreamProjectionChecker.isAssignableFromType(supertype)) { + final typeArgs = supertype.typeArguments; + if (typeArgs.isNotEmpty) { + return (typeArgs[0], null); + } + } + if (_multiStreamProjectionChecker.isAssignableFromType(supertype)) { + final typeArgs = supertype.typeArguments; + if (typeArgs.length >= 2) { + return (typeArgs[0], typeArgs[1]); + } + } + } + } + + return (null, null); + } +} diff --git a/packages/continuum_generator/test/projection_code_emitter_test.dart b/packages/continuum_generator/test/projection_code_emitter_test.dart new file mode 100644 index 0000000..fa23716 --- /dev/null +++ b/packages/continuum_generator/test/projection_code_emitter_test.dart @@ -0,0 +1,65 @@ +import 'package:analyzer/dart/element/element.dart'; +import 'package:build/build.dart'; +import 'package:build_test/build_test.dart'; +import 'package:continuum_generator/src/projection_code_emitter.dart'; +import 'package:continuum_generator/src/projection_discovery.dart'; +import 'package:test/test.dart'; + +void main() { + group('ProjectionCodeEmitter', () { + test('emits apply() using StoredEvent.domainEvent (not data map)', () async { + final inputs = { + 'continuum_generator|lib/projection_domain.dart': r''' +import 'package:continuum/src/annotations/projection.dart'; +import 'package:continuum/src/projections/single_stream_projection.dart'; +import 'package:continuum/src/events/continuum_event.dart'; + +part 'projection_domain.g.dart'; + +class UserRegistered implements ContinuumEvent { + const UserRegistered(); + + @override + String get id => 'id'; + + @override + DateTime get occurredOn => DateTime(1970); + + @override + Map get metadata => const {}; +} + +@Projection(name: 'user-profile', events: [UserRegistered]) +class UserProfileProjection extends SingleStreamProjection + with _$UserProfileProjectionHandlers { + @override + int createInitial(streamId) => 0; + + @override + int applyUserRegistered(int current, UserRegistered event) => current + 1; +} +''', + }; + + final output = await resolveSources( + inputs, + (resolver) async { + final library = await _libraryFor(resolver, 'continuum_generator|lib/projection_domain.dart'); + final projections = ProjectionDiscovery().discoverProjections(library); + return ProjectionCodeEmitter().emit(projections); + }, + rootPackage: 'continuum_generator', + readAllSourcesFromFilesystem: true, + ); + + expect(output, contains('final domainEvent = event.domainEvent;')); + expect(output, isNot(contains('final domainEvent = event.data;'))); + expect(output, contains('StoredEvent.domainEvent is null')); + }); + }); +} + +Future _libraryFor(Resolver resolver, String serializedAssetId) async { + final assetId = AssetId.parse(serializedAssetId); + return resolver.libraryFor(assetId); +} diff --git a/packages/continuum_lints/lib/continuum_lints.dart b/packages/continuum_lints/lib/continuum_lints.dart index 57ff5db..6b8b50d 100644 --- a/packages/continuum_lints/lib/continuum_lints.dart +++ b/packages/continuum_lints/lib/continuum_lints.dart @@ -2,6 +2,7 @@ import 'package:custom_lint_builder/custom_lint_builder.dart'; import 'src/continuum_missing_apply_handlers_rule.dart'; import 'src/continuum_missing_creation_factories_rule.dart'; +import 'src/continuum_missing_projection_handlers_rule.dart'; /// Creates the custom_lint plugin for continuum. PluginBase createPlugin() => _ContinuumLintsPlugin(); @@ -14,6 +15,7 @@ final class _ContinuumLintsPlugin extends PluginBase { return const [ ContinuumMissingApplyHandlersRule(), ContinuumMissingCreationFactoriesRule(), + ContinuumMissingProjectionHandlersRule(), ]; } } diff --git a/packages/continuum_lints/lib/src/continuum_implement_missing_projection_handlers_fix.dart b/packages/continuum_lints/lib/src/continuum_implement_missing_projection_handlers_fix.dart new file mode 100644 index 0000000..f30baa5 --- /dev/null +++ b/packages/continuum_lints/lib/src/continuum_implement_missing_projection_handlers_fix.dart @@ -0,0 +1,206 @@ +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/diagnostic/diagnostic.dart'; +import 'package:analyzer_plugin/utilities/change_builder/change_builder_dart.dart'; +import 'package:custom_lint_builder/custom_lint_builder.dart'; + +import 'continuum_required_projection_handlers.dart'; + +/// Quick-fix that inserts stub implementations for missing `apply(...)` +/// and `createInitial()` handlers required by the generated +/// `_$Handlers` mixin. +final class ContinuumImplementMissingProjectionHandlersFix extends DartFix { + static final Object _resolvedUnitKey = Object(); + + /// Unique ID for this fix (used for batching). + static const String fixId = 'continuum_implement_missing_projection_handlers'; + + /// The lint code this fix targets. + static const String _targetLintCodeName = 'continuum_missing_projection_handlers'; + + /// Creates a quick-fix that implements missing projection handlers. + ContinuumImplementMissingProjectionHandlersFix(); + + @override + Future startUp( + CustomLintResolver resolver, + CustomLintContext context, + ) async { + context.sharedState[_resolvedUnitKey] = await resolver.getResolvedUnitResult(); + } + + @override + String get id => fixId; + + @override + void run( + CustomLintResolver resolver, + ChangeReporter reporter, + CustomLintContext context, + Diagnostic analysisError, + List others, + ) { + if (analysisError.diagnosticCode.name != _targetLintCodeName) return; + + final ResolvedUnitResult? unitResult = context.sharedState[_resolvedUnitKey] as ResolvedUnitResult?; + if (unitResult == null) return; + + final ClassDeclaration? classDeclaration = _enclosingClassDeclaration( + unitResult.unit, + analysisError.offset, + ); + + if (classDeclaration == null) return; + + final ClassElement? classElement = classDeclaration.declaredFragment?.element; + if (classElement == null) return; + + final List missingMethods = const ContinuumRequiredProjectionHandlers().findMissingProjectionHandlerMethods(classElement); + if (missingMethods.isEmpty) return; + + final int insertionOffset = classDeclaration.rightBracket.offset; + + final String classIndent = _indentForLineAtOffset( + unitResult.content, + unitResult.lineInfo.getOffsetOfLine( + unitResult.lineInfo.getLocation(classDeclaration.offset).lineNumber - 1, + ), + ); + + final String memberIndent = '$classIndent '; + + final StringBuffer buffer = StringBuffer(); + + for (final MethodElement method in missingMethods) { + buffer.write(_renderStubMethod(method, memberIndent)); + } + + final ChangeBuilder changeBuilder = reporter.createChangeBuilder( + message: 'Implement missing projection handlers', + priority: 0, + ); + + changeBuilder.addDartFileEdit((DartFileEditBuilder builder) { + builder.addSimpleInsertion(insertionOffset, buffer.toString()); + }); + } + + /// Finds the class declaration containing the given offset. + ClassDeclaration? _enclosingClassDeclaration( + CompilationUnit unit, + int offset, + ) { + for (final CompilationUnitMember declaration in unit.declarations) { + if (declaration is! ClassDeclaration) continue; + + if (declaration.offset <= offset && offset <= declaration.end) { + return declaration; + } + } + + return null; + } + + /// Extracts the indentation string for a line at the given offset. + String _indentForLineAtOffset(String source, int lineStartOffset) { + final int lineEndOffset = source.indexOf('\n', lineStartOffset); + + final String line = lineEndOffset == -1 ? source.substring(lineStartOffset) : source.substring(lineStartOffset, lineEndOffset); + + final int firstNonWhitespace = line.indexOf(RegExp(r'\S')); + if (firstNonWhitespace == -1) return ''; + + return line.substring(0, firstNonWhitespace); + } + + /// Renders a stub method implementation. + String _renderStubMethod(MethodElement method, String indent) { + final String returnType = method.returnType.getDisplayString(); + final String name = method.displayName; + + final _ParameterGroups parameters = _renderParameters(method.formalParameters); + + final StringBuffer buffer = StringBuffer(); + + buffer.write('\n'); + buffer.write('$indent@override\n'); + buffer.write('$indent$returnType $name('); + buffer.write(parameters.positional); + + if (parameters.positional.isNotEmpty && (parameters.optionalPositional.isNotEmpty || parameters.named.isNotEmpty)) { + buffer.write(', '); + } + + if (parameters.optionalPositional.isNotEmpty) { + buffer.write('[${parameters.optionalPositional}]'); + + if (parameters.named.isNotEmpty) { + buffer.write(', '); + } + } + + if (parameters.named.isNotEmpty) { + buffer.write('{${parameters.named}}'); + } + + buffer.write(') {\n'); + buffer.write('$indent // TODO: Implement handler\n'); + buffer.write('$indent throw UnimplementedError();\n'); + buffer.write('$indent}\n'); + + return buffer.toString(); + } + + /// Groups parameters by kind for rendering. + _ParameterGroups _renderParameters(List parameters) { + final List positional = []; + final List optionalPositional = []; + final List named = []; + + for (final FormalParameterElement parameter in parameters) { + final String type = parameter.type.getDisplayString(); + final String name = parameter.displayName; + + final String parameterString; + if (parameter.isRequiredNamed) { + parameterString = 'required $type $name'; + } else { + parameterString = '$type $name'; + } + + if (parameter.isNamed) { + named.add(parameterString); + } else if (parameter.isOptionalPositional) { + optionalPositional.add(parameterString); + } else { + positional.add(parameterString); + } + } + + return _ParameterGroups( + positional: positional.join(', '), + optionalPositional: optionalPositional.join(', '), + named: named.join(', '), + ); + } +} + +/// Groups of parameters by kind. +final class _ParameterGroups { + /// Creates parameter groups. + const _ParameterGroups({ + required this.positional, + required this.optionalPositional, + required this.named, + }); + + /// Positional parameters as a comma-separated string. + final String positional; + + /// Optional positional parameters as a comma-separated string. + final String optionalPositional; + + /// Named parameters as a comma-separated string. + final String named; +} diff --git a/packages/continuum_lints/lib/src/continuum_missing_projection_handlers_rule.dart b/packages/continuum_lints/lib/src/continuum_missing_projection_handlers_rule.dart new file mode 100644 index 0000000..ba9d61b --- /dev/null +++ b/packages/continuum_lints/lib/src/continuum_missing_projection_handlers_rule.dart @@ -0,0 +1,68 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/error/error.dart' hide LintCode; +import 'package:analyzer/error/listener.dart'; +import 'package:custom_lint_builder/custom_lint_builder.dart'; + +import 'continuum_implement_missing_projection_handlers_fix.dart'; +import 'continuum_required_projection_handlers.dart'; + +/// Reports when a non-abstract `@Projection()` class is missing required +/// `apply(...)` handlers declared by the generated +/// `_$Handlers` mixin. +/// +/// Why this exists: +/// - Dart allows classes to become *implicitly abstract* when they do not +/// implement interface members. +/// - That means missing handlers may not surface as a compile error until the +/// class is instantiated. +/// - This lint surfaces the problem immediately in the editor. +final class ContinuumMissingProjectionHandlersRule extends DartLintRule { + static const LintCode _lintCode = LintCode( + name: 'continuum_missing_projection_handlers', + problemMessage: 'This @Projection() class mixes in generated handlers but is missing methods: {0}.', + correctionMessage: 'Implement the missing createInitial() and apply(...) methods.', + errorSeverity: DiagnosticSeverity.WARNING, + ); + + static final TypeChecker _projectionChecker = const TypeChecker.fromUrl( + 'package:continuum/src/annotations/projection.dart#Projection', + ); + + /// Creates the lint rule. + const ContinuumMissingProjectionHandlersRule() : super(code: _lintCode); + + @override + List getFixes() { + return [ContinuumImplementMissingProjectionHandlersFix()]; + } + + @override + void run( + CustomLintResolver resolver, + DiagnosticReporter reporter, + CustomLintContext context, + ) { + context.registry.addClassDeclaration((ClassDeclaration node) { + final ClassElement? classElement = node.declaredFragment?.element; + if (classElement == null) return; + + // Only check @Projection annotated classes. + if (!_projectionChecker.hasAnnotationOf(classElement)) return; + + // If the user explicitly made the class abstract, they can defer handler + // implementations to concrete subtypes. + if (node.abstractKeyword != null) return; + + final List missingHandlers = const ContinuumRequiredProjectionHandlers().findMissingProjectionHandlers(classElement); + + if (missingHandlers.isEmpty) return; + + reporter.atElement2( + classElement, + _lintCode, + arguments: [missingHandlers.join(', ')], + ); + }); + } +} diff --git a/packages/continuum_lints/lib/src/continuum_required_projection_handlers.dart b/packages/continuum_lints/lib/src/continuum_required_projection_handlers.dart new file mode 100644 index 0000000..5e94d79 --- /dev/null +++ b/packages/continuum_lints/lib/src/continuum_required_projection_handlers.dart @@ -0,0 +1,106 @@ +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/type.dart'; +import 'package:custom_lint_builder/custom_lint_builder.dart'; + +/// Computes which `apply...` handlers a concrete projection is still missing. +/// +/// The handler requirements are derived from the generated +/// `_$Handlers` mixin. +final class ContinuumRequiredProjectionHandlers { + static final TypeChecker _projectionChecker = const TypeChecker.fromUrl( + 'package:continuum/src/annotations/projection.dart#Projection', + ); + + /// Creates a requirement checker for generated continuum projection handlers. + const ContinuumRequiredProjectionHandlers(); + + /// Returns the list of missing projection handler method names. + /// + /// If the class does not mix in `_$Handlers`, this returns an + /// empty list. + List findMissingProjectionHandlers(ClassElement classElement) { + final List missingMethods = findMissingProjectionHandlerMethods(classElement); + + return missingMethods.map((MethodElement method) => method.displayName).toList(growable: false); + } + + /// Returns the list of missing projection handler method elements. + /// + /// This is useful for generating method stubs in quick-fixes. + /// + /// If the class does not mix in `_$Handlers`, this returns an + /// empty list. + List findMissingProjectionHandlerMethods( + ClassElement classElement, + ) { + // First verify this is a @Projection annotated class. + if (!_projectionChecker.hasAnnotationOf(classElement)) { + return const []; + } + + final InterfaceType? handlersMixinType = _findHandlersMixinType(classElement); + if (handlersMixinType == null) { + return const []; + } + + // Find all required abstract methods from the mixin. + // These include createInitial and apply methods. + final List requiredMethods = handlersMixinType.element.methods.where((MethodElement method) => method.isAbstract).toList(growable: false); + + if (requiredMethods.isEmpty) { + return const []; + } + + final List missing = []; + + for (final MethodElement requiredMethod in requiredMethods) { + final MethodElement? concreteImplementation = _findConcreteMethodImplementation( + classElement, + requiredMethod.displayName, + ); + if (concreteImplementation == null) { + missing.add(requiredMethod); + } + } + + return missing; + } + + /// Finds the `_$Handlers` mixin type in the class's mixins. + InterfaceType? _findHandlersMixinType(ClassElement classElement) { + final String className = classElement.displayName; + final String expectedMixinName = '_\$${className}Handlers'; + + for (final InterfaceType mixinType in classElement.mixins) { + if (mixinType.element.name == expectedMixinName) { + return mixinType; + } + } + + return null; + } + + /// Finds a concrete method implementation in the class hierarchy. + MethodElement? _findConcreteMethodImplementation( + ClassElement classElement, + String methodName, + ) { + // Check the class itself first. + for (final MethodElement method in classElement.methods) { + if (method.displayName == methodName && !method.isAbstract) { + return method; + } + } + + // Check superclasses (but not mixins, since we want user implementations). + final InterfaceType? supertype = classElement.supertype; + if (supertype != null && supertype.element is ClassElement) { + return _findConcreteMethodImplementation( + supertype.element as ClassElement, + methodName, + ); + } + + return null; + } +} diff --git a/packages/continuum_store_hive/lib/src/hive_event_store.dart b/packages/continuum_store_hive/lib/src/hive_event_store.dart index 72617a5..44d7a01 100644 --- a/packages/continuum_store_hive/lib/src/hive_event_store.dart +++ b/packages/continuum_store_hive/lib/src/hive_event_store.dart @@ -201,6 +201,7 @@ final class HiveEventStore implements AtomicEventStore, ProjectionEventStore { occurredOn: event.occurredOn, metadata: event.metadata, globalSequence: _globalSequence++, + domainEvent: event.domainEvent, ); eventKeys.add(_eventKey(streamId, nextVersion)); diff --git a/packages/continuum_store_memory/lib/src/in_memory_event_store.dart b/packages/continuum_store_memory/lib/src/in_memory_event_store.dart index 0515552..1706487 100644 --- a/packages/continuum_store_memory/lib/src/in_memory_event_store.dart +++ b/packages/continuum_store_memory/lib/src/in_memory_event_store.dart @@ -61,6 +61,7 @@ final class InMemoryEventStore implements AtomicEventStore, ProjectionEventStore occurredOn: event.occurredOn, metadata: event.metadata, globalSequence: _globalSequence++, + domainEvent: event.domainEvent, ), ); nextVersion++; diff --git a/packages/continuum_store_memory/test/in_memory_event_store_test.dart b/packages/continuum_store_memory/test/in_memory_event_store_test.dart index 6e8cde6..64883cd 100644 --- a/packages/continuum_store_memory/test/in_memory_event_store_test.dart +++ b/packages/continuum_store_memory/test/in_memory_event_store_test.dart @@ -127,6 +127,25 @@ void main() { expect(events1[0].globalSequence, equals(0)); expect(events2[0].globalSequence, equals(1)); }); + + test('preserves StoredEvent.domainEvent when appending', () async { + final streamId = const StreamId('domain_event_stream'); + final domainEvent = _TestDomainEvent(eventId: EventId.fromUlid()); + + final stored = StoredEvent.fromContinuumEvent( + continuumEvent: domainEvent, + streamId: streamId, + version: 0, + eventType: 'domain.test', + data: const {'k': 'v'}, + ); + + await store.appendEventsAsync(streamId, ExpectedVersion.noStream, [stored]); + + final loaded = await store.loadStreamAsync(streamId); + expect(loaded, hasLength(1)); + expect(loaded.single.domainEvent, same(domainEvent)); + }); }); group('appendEventsToStreamsAsync', () { @@ -324,6 +343,25 @@ void main() { }); } +final class _TestDomainEvent implements ContinuumEvent { + _TestDomainEvent({ + required EventId eventId, + DateTime? occurredOn, + Map metadata = const {}, + }) : id = eventId, + occurredOn = occurredOn ?? DateTime.now(), + metadata = Map.unmodifiable(metadata); + + @override + final EventId id; + + @override + final DateTime occurredOn; + + @override + final Map metadata; +} + /// Helper to create a stored event for testing. StoredEvent _createStoredEvent(StreamId streamId, int version, String eventType) { return StoredEvent( From 3b732d64ff27c2832d95a44b1ecfe9c74684d291 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 19 Jan 2026 14:59:36 +0800 Subject: [PATCH 5/9] More tests --- .../async_projection_executor.dart | 14 +- .../inline_projection_executor.dart | 36 +-- .../src/projections/projection_processor.dart | 15 +- .../async_projection_executor_test.dart | 235 +++++++++++++++++- .../generated_projection_dispatch_test.dart | 40 +++ .../inline_projection_executor_test.dart | 108 +++++++- .../projection_processor_test.dart | 103 +++++++- 7 files changed, 505 insertions(+), 46 deletions(-) diff --git a/packages/continuum/lib/src/projections/async_projection_executor.dart b/packages/continuum/lib/src/projections/async_projection_executor.dart index e35d232..3afeaae 100644 --- a/packages/continuum/lib/src/projections/async_projection_executor.dart +++ b/packages/continuum/lib/src/projections/async_projection_executor.dart @@ -62,7 +62,19 @@ final class AsyncProjectionExecutor { StoredEvent event, { String schemaHash = '', }) async { - final projections = _registry.asyncProjections; + final domainEvent = event.domainEvent; + if (domainEvent == null) { + // Without a deserialized domain event we cannot route by runtime type. + // Count this as a failure for all async projections. + return ProcessingResult( + processed: 0, + failed: _registry.asyncProjections.length, + ); + } + + final projections = _registry.getAsyncProjectionsForEventType( + domainEvent.runtimeType, + ); var processed = 0; var failed = 0; diff --git a/packages/continuum/lib/src/projections/inline_projection_executor.dart b/packages/continuum/lib/src/projections/inline_projection_executor.dart index 760dc05..a3fed1f 100644 --- a/packages/continuum/lib/src/projections/inline_projection_executor.dart +++ b/packages/continuum/lib/src/projections/inline_projection_executor.dart @@ -47,35 +47,23 @@ final class InlineProjectionExecutor { /// Processes a single event through all matching inline projections. Future _processEventAsync(StoredEvent event) async { - // Look up projections by the stored event type string. - // Since we don't have the runtime Type, we need to check all inline projections. - final projections = _registry.inlineProjections; + final domainEvent = event.domainEvent; + if (domainEvent == null) { + throw StateError( + 'StoredEvent.domainEvent is null. ' + 'Projections require deserialized domain events.', + ); + } + + final projections = _registry.getInlineProjectionsForEventType( + domainEvent.runtimeType, + ); for (final registration in projections) { - // Check if this projection handles this event type by checking the eventType string. - // Since we store event type as string, we need the projection to declare string-based matching - // or we use the registered Type set. For now, we'll iterate and let the projection decide. - if (_shouldProcess(registration, event)) { - await _applyEventToProjectionAsync(registration, event); - } + await _applyEventToProjectionAsync(registration, event); } } - /// Checks if a projection should process the given event. - /// - /// This is a temporary implementation. In practice, projections should - /// declare which event type strings they handle, or the registry should - /// maintain a mapping from event type strings to projections. - bool _shouldProcess( - ProjectionRegistration registration, - StoredEvent event, - ) { - // For now, always process. The projection's apply method should be - // designed to handle only events it cares about. - // A more sophisticated implementation would use event type string matching. - return true; - } - /// Applies an event to a projection, updating its read model. Future _applyEventToProjectionAsync( ProjectionRegistration registration, diff --git a/packages/continuum/lib/src/projections/projection_processor.dart b/packages/continuum/lib/src/projections/projection_processor.dart index dca2e10..951216b 100644 --- a/packages/continuum/lib/src/projections/projection_processor.dart +++ b/packages/continuum/lib/src/projections/projection_processor.dart @@ -174,13 +174,22 @@ final class PollingProjectionProcessor implements ProjectionProcessor { } // Process events through the executor. - final result = await _executor.processEventsAsync(events); + // Use the current processor schema hash so projection positions record it. + final schemaHash = lastPosition?.schemaHash ?? ''; + final result = await _executor.processEventsAsync( + events, + schemaHash: schemaHash, + ); + + // Do not advance the processor position if anything failed. + // This matters because advancing would permanently skip retries. + if (result.failed != 0) { + return result; + } // Update overall processor position to the last event's global sequence. final lastEvent = events.last; if (lastEvent.globalSequence != null) { - // Use the schema hash from the last position, or empty string for new projections. - final schemaHash = lastPosition?.schemaHash ?? ''; await _positionStore.savePositionAsync( _positionKey, ProjectionPosition( diff --git a/packages/continuum/test/projections/async_projection_executor_test.dart b/packages/continuum/test/projections/async_projection_executor_test.dart index 9459db7..77521af 100644 --- a/packages/continuum/test/projections/async_projection_executor_test.dart +++ b/packages/continuum/test/projections/async_projection_executor_test.dart @@ -16,20 +16,26 @@ void main() { }); test('processEventsAsync does nothing when no projections registered', () async { + // Arrange: No projections are registered. executor = AsyncProjectionExecutor( registry: registry, positionStore: positionStore, ); final event = _createEvent(streamId: 'stream-1', globalSequence: 1); + + // Act. final result = await executor.processEventsAsync([event]); + // Assert: No projections means no work. + // This matters because projections are optional infrastructure. expect(result.processed, equals(0)); expect(result.failed, equals(0)); expect(result.isSuccess, isTrue); }); test('processEventsAsync does nothing with empty event list', () async { + // Arrange. final projection = _CounterProjection(); registry.registerAsync(projection, readModelStore); executor = AsyncProjectionExecutor( @@ -37,13 +43,16 @@ void main() { positionStore: positionStore, ); + // Act. final result = await executor.processEventsAsync([]); + // Assert: Empty input should be a no-op. expect(result.processed, equals(0)); expect(result.total, equals(0)); }); test('processEventsAsync creates initial read model', () async { + // Arrange. final projection = _CounterProjection(); registry.registerAsync(projection, readModelStore); executor = AsyncProjectionExecutor( @@ -52,14 +61,18 @@ void main() { ); final event = _createEvent(streamId: 'stream-1', globalSequence: 1); + + // Act. await executor.processEventsAsync([event]); + // Assert: Missing read model should be created via createInitial. final readModel = await readModelStore.loadAsync(const StreamId('stream-1')); expect(readModel, isNotNull); expect(readModel!.count, equals(1)); }); test('processEventsAsync updates existing read model', () async { + // Arrange. final projection = _CounterProjection(); registry.registerAsync(projection, readModelStore); executor = AsyncProjectionExecutor( @@ -74,13 +87,17 @@ void main() { ); final event = _createEvent(streamId: 'stream-1', globalSequence: 5); + + // Act. await executor.processEventsAsync([event]); + // Assert: Existing models should be updated, not replaced incorrectly. final readModel = await readModelStore.loadAsync(const StreamId('stream-1')); expect(readModel!.count, equals(11)); }); test('processEventsAsync tracks position after success', () async { + // Arrange. final projection = _CounterProjection(); registry.registerAsync(projection, readModelStore); executor = AsyncProjectionExecutor( @@ -96,11 +113,13 @@ void main() { await executor.processEventsAsync(events); + // Assert: Position should reflect the last successfully processed event. final position = await positionStore.loadPositionAsync('counter'); expect(position?.lastProcessedSequence, equals(12)); }); test('processEventsAsync skips inline projections', () async { + // Arrange. final inlineProjection = _CounterProjection('inline'); final asyncProjection = _CounterProjection('async'); final inlineStore = InMemoryReadModelStore<_CounterReadModel, StreamId>(); @@ -113,13 +132,17 @@ void main() { ); final event = _createEvent(streamId: 'stream-1', globalSequence: 1); + + // Act. await executor.processEventsAsync([event]); + // Assert: Inline projections are not executed by the async executor. expect(inlineStore.length, equals(0)); expect(readModelStore.length, equals(1)); }); test('processEventsAsync continues after projection failure', () async { + // Arrange. final failingProjection = _FailingProjection(); final workingProjection = _CounterProjection('working'); final failingStore = InMemoryReadModelStore(); @@ -133,6 +156,8 @@ void main() { ); final event = _createEvent(streamId: 'stream-1', globalSequence: 1); + + // Act. final result = await executor.processEventsAsync([event]); // Failing projection failed, working projection succeeded. @@ -144,6 +169,102 @@ void main() { expect(workingStore.length, equals(1)); }); + test('processEventsAsync routes events only to matching projections', () async { + // Arrange: Two projections with disjoint handled event types. + final storeA = InMemoryReadModelStore(); + final storeB = InMemoryReadModelStore(); + registry.registerAsync(_CounterProjectionForA(), storeA); + registry.registerAsync(_CounterProjectionForB(), storeB); + executor = AsyncProjectionExecutor( + registry: registry, + positionStore: positionStore, + ); + + final eventA = _createStoredEvent( + streamId: const StreamId('s1'), + globalSequence: 0, + continuumEvent: _TestEventA(eventId: EventId.fromUlid()), + ); + + // Act. + final result = await executor.processEventsAsync([eventA]); + + // Assert: Only the matching projection should be updated. + // This matters because unrelated projections must not mutate read models. + expect(result.processed, equals(1)); + expect(result.failed, equals(0)); + expect(await storeA.loadAsync(const StreamId('s1')), equals(1)); + expect(await storeB.loadAsync(const StreamId('s1')), isNull); + }); + + test('processEventsAsync saves schemaHash in position on success', () async { + // Arrange. + final store = InMemoryReadModelStore(); + final projection = _CounterProjectionForA(); + const schemaHash = 'schema-1'; + registry.registerAsync(projection, store); + executor = AsyncProjectionExecutor( + registry: registry, + positionStore: positionStore, + ); + + final eventA = _createStoredEvent( + streamId: const StreamId('s1'), + globalSequence: 5, + continuumEvent: _TestEventA(eventId: EventId.fromUlid()), + ); + + // Act. + final result = await executor.processEventsAsync( + [eventA], + schemaHash: schemaHash, + ); + + // Assert: The position must include schema hash for rebuild detection. + expect(result.isSuccess, isTrue); + final position = await positionStore.loadPositionAsync( + projection.projectionName, + ); + expect(position?.schemaHash, equals(schemaHash)); + }); + + test('processEventsAsync does not advance position for failing projection', () async { + // Arrange: One successful and one failing projection. + final storeOk = InMemoryReadModelStore(); + final storeFail = InMemoryReadModelStore(); + final okProjection = _CounterProjectionForA(); + final failingProjection = _FailingProjectionForA(); + registry.registerAsync(okProjection, storeOk); + registry.registerAsync(failingProjection, storeFail); + executor = AsyncProjectionExecutor( + registry: registry, + positionStore: positionStore, + ); + + final eventA = _createStoredEvent( + streamId: const StreamId('s1'), + globalSequence: 7, + continuumEvent: _TestEventA(eventId: EventId.fromUlid()), + ); + + // Act. + final result = await executor.processEventsAsync([eventA]); + + // Assert: Failures should be isolated per projection. + // This matters because successful projections should still advance. + expect(result.processed, equals(1)); + expect(result.failed, equals(1)); + + final okPosition = await positionStore.loadPositionAsync( + okProjection.projectionName, + ); + final failPosition = await positionStore.loadPositionAsync( + failingProjection.projectionName, + ); + expect(okPosition?.lastProcessedSequence, equals(7)); + expect(failPosition, isNull); + }); + test('processEventsAsync returns correct counts for multiple events', () async { final projection = _CounterProjection(); registry.registerAsync(projection, readModelStore); @@ -233,14 +354,14 @@ StoredEvent _createEvent({ required String streamId, int? globalSequence, }) { - return StoredEvent( - eventId: EventId('evt-${_eventCounter++}'), + final continuumEvent = _TestEvent(eventId: EventId('evt-${_eventCounter++}')); + + return StoredEvent.fromContinuumEvent( + continuumEvent: continuumEvent, streamId: StreamId(streamId), version: 0, eventType: 'test.event', - data: const {}, - occurredOn: DateTime.now(), - metadata: const {}, + data: const {}, globalSequence: globalSequence, ); } @@ -277,7 +398,109 @@ class _CounterProjection extends SingleStreamProjection<_CounterReadModel> { } } -class _TestEvent {} +StoredEvent _createStoredEvent({ + required StreamId streamId, + required int globalSequence, + required ContinuumEvent continuumEvent, +}) { + return StoredEvent.fromContinuumEvent( + continuumEvent: continuumEvent, + streamId: streamId, + version: 0, + eventType: 'test.event', + data: const {}, + globalSequence: globalSequence, + ); +} + +final class _TestEvent implements ContinuumEvent { + _TestEvent({ + required EventId eventId, + DateTime? occurredOn, + Map metadata = const {}, + }) : id = eventId, + occurredOn = occurredOn ?? DateTime.now(), + metadata = Map.unmodifiable(metadata); + + @override + final EventId id; + + @override + final DateTime occurredOn; + + @override + final Map metadata; +} + +final class _TestEventA implements ContinuumEvent { + _TestEventA({required EventId eventId}) : id = eventId; + + @override + final EventId id; + + @override + DateTime get occurredOn => DateTime.now(); + + @override + Map get metadata => const {}; +} + +final class _TestEventB implements ContinuumEvent { + _TestEventB({required EventId eventId}) : id = eventId; + + @override + final EventId id; + + @override + DateTime get occurredOn => DateTime.now(); + + @override + Map get metadata => const {}; +} + +final class _CounterProjectionForA extends SingleStreamProjection { + @override + Set get handledEventTypes => const {_TestEventA}; + + @override + String get projectionName => 'counter-a'; + + @override + int createInitial(StreamId streamId) => 0; + + @override + int apply(int current, StoredEvent event) => current + 1; +} + +final class _CounterProjectionForB extends SingleStreamProjection { + @override + Set get handledEventTypes => const {_TestEventB}; + + @override + String get projectionName => 'counter-b'; + + @override + int createInitial(StreamId streamId) => 0; + + @override + int apply(int current, StoredEvent event) => current + 1; +} + +final class _FailingProjectionForA extends SingleStreamProjection { + @override + Set get handledEventTypes => const {_TestEventA}; + + @override + String get projectionName => 'failing-a'; + + @override + int createInitial(StreamId streamId) => 0; + + @override + int apply(int current, StoredEvent event) { + throw StateError('Intentional failure for testing'); + } +} class _FailingProjection extends SingleStreamProjection { @override diff --git a/packages/continuum/test/projections/generated_projection_dispatch_test.dart b/packages/continuum/test/projections/generated_projection_dispatch_test.dart index 61ff377..4c29e05 100644 --- a/packages/continuum/test/projections/generated_projection_dispatch_test.dart +++ b/packages/continuum/test/projections/generated_projection_dispatch_test.dart @@ -51,6 +51,27 @@ void main() { throwsA(isA()), ); }); + + test('apply throws UnsupportedEventException for unsupported domainEvent type', () { + // Arrange: A projection that supports only A and B. + final projection = _GeneratedStyleProjection(); + + final eventC = _TestEventC(eventId: EventId.fromUlid()); + final storedC = StoredEvent.fromContinuumEvent( + continuumEvent: eventC, + streamId: const StreamId('s1'), + version: 0, + eventType: 'test.c', + data: const {'c': 1}, + ); + + // Act/Assert: Unsupported events must fail fast. + // This matters because applying the wrong event type is a programming error. + expect( + () => projection.apply(0, storedC), + throwsA(isA()), + ); + }); }); } @@ -132,3 +153,22 @@ final class _TestEventB implements ContinuumEvent { @override final Map metadata; } + +final class _TestEventC implements ContinuumEvent { + _TestEventC({ + required EventId eventId, + DateTime? occurredOn, + Map metadata = const {}, + }) : id = eventId, + occurredOn = occurredOn ?? DateTime.now(), + metadata = Map.unmodifiable(metadata); + + @override + final EventId id; + + @override + final DateTime occurredOn; + + @override + final Map metadata; +} diff --git a/packages/continuum/test/projections/inline_projection_executor_test.dart b/packages/continuum/test/projections/inline_projection_executor_test.dart index bf1f66a..3fc556a 100644 --- a/packages/continuum/test/projections/inline_projection_executor_test.dart +++ b/packages/continuum/test/projections/inline_projection_executor_test.dart @@ -142,6 +142,31 @@ void main() { throwsA(isA()), ); }); + + test('executeAsync routes events only to matching projections', () async { + // Arrange: Two projections that handle different event types. + final storeA = InMemoryReadModelStore(); + final storeB = InMemoryReadModelStore(); + registry.registerInline(_CounterProjectionForA(), storeA); + registry.registerInline(_CounterProjectionForB(), storeB); + executor = InlineProjectionExecutor(registry: registry); + + final storedEventA = StoredEvent.fromContinuumEvent( + continuumEvent: _TestEventA(eventId: EventId.fromUlid()), + streamId: const StreamId('stream-1'), + version: 0, + eventType: 'test.a', + data: const {}, + ); + + // Act. + await executor.executeAsync([storedEventA]); + + // Assert: Only the matching projection should be updated. + // This matters because unrelated projections must not mutate read models. + expect(await storeA.loadAsync(const StreamId('stream-1')), equals(1)); + expect(await storeB.loadAsync(const StreamId('stream-1')), isNull); + }); }); } @@ -153,14 +178,14 @@ StoredEvent _createEvent({ required String streamId, int version = 0, }) { - return StoredEvent( - eventId: EventId('evt-${_eventCounter++}'), + final continuumEvent = _TestEvent(eventId: EventId('evt-${_eventCounter++}')); + + return StoredEvent.fromContinuumEvent( + continuumEvent: continuumEvent, streamId: StreamId(streamId), version: version, eventType: 'test.counter_incremented', - data: const {}, - occurredOn: DateTime.now(), - metadata: const {}, + data: const {}, ); } @@ -196,7 +221,78 @@ class _CounterProjection extends SingleStreamProjection<_CounterReadModel> { } } -class _TestEvent {} +final class _TestEvent implements ContinuumEvent { + _TestEvent({ + required EventId eventId, + DateTime? occurredOn, + Map metadata = const {}, + }) : id = eventId, + occurredOn = occurredOn ?? DateTime.now(), + metadata = Map.unmodifiable(metadata); + + @override + final EventId id; + + @override + final DateTime occurredOn; + + @override + final Map metadata; +} + +final class _TestEventA implements ContinuumEvent { + _TestEventA({required EventId eventId}) : id = eventId; + + @override + final EventId id; + + @override + DateTime get occurredOn => DateTime.now(); + + @override + Map get metadata => const {}; +} + +final class _TestEventB implements ContinuumEvent { + _TestEventB({required EventId eventId}) : id = eventId; + + @override + final EventId id; + + @override + DateTime get occurredOn => DateTime.now(); + + @override + Map get metadata => const {}; +} + +final class _CounterProjectionForA extends SingleStreamProjection { + @override + Set get handledEventTypes => const {_TestEventA}; + + @override + String get projectionName => 'counter-a'; + + @override + int createInitial(StreamId streamId) => 0; + + @override + int apply(int current, StoredEvent event) => current + 1; +} + +final class _CounterProjectionForB extends SingleStreamProjection { + @override + Set get handledEventTypes => const {_TestEventB}; + + @override + String get projectionName => 'counter-b'; + + @override + int createInitial(StreamId streamId) => 0; + + @override + int apply(int current, StoredEvent event) => current + 1; +} class _FailingProjection extends SingleStreamProjection { @override diff --git a/packages/continuum/test/projections/projection_processor_test.dart b/packages/continuum/test/projections/projection_processor_test.dart index a6089b2..99838dc 100644 --- a/packages/continuum/test/projections/projection_processor_test.dart +++ b/packages/continuum/test/projections/projection_processor_test.dart @@ -176,6 +176,62 @@ void main() { expect(processor.isRunning, isFalse); }); + + test('processBatchAsync does not advance processor position when any projection fails', () async { + // Arrange: A failing projection and one event. + registry = ProjectionRegistry(); + positionStore = InMemoryProjectionPositionStore(); + readModelStore = InMemoryReadModelStore<_CounterReadModel, StreamId>(); + eventStore = [ + _createEvent('s1', globalSequence: 0), + ]; + + registry.registerAsync(_FailingProjection(), readModelStore); + executor = AsyncProjectionExecutor( + registry: registry, + positionStore: positionStore, + ); + + // Seed the processor position so we can assert it stays unchanged. + await positionStore.savePositionAsync( + '_processor_position', + const ProjectionPosition(lastProcessedSequence: -1, schemaHash: 'test-schema'), + ); + + processor = createProcessor(); + + // Act. + final result = await processor.processBatchAsync(); + + // Assert: Do not skip failed events. + // This matters because advancing would permanently lose retries. + expect(result.failed, greaterThan(0)); + final processorPosition = await positionStore.loadPositionAsync('_processor_position'); + expect(processorPosition?.lastProcessedSequence, equals(-1)); + }); + + test('processBatchAsync passes schemaHash into executor for projection positions', () async { + // Arrange: A successful projection and one event. + eventStore = [ + _createEvent('s1', globalSequence: 10), + ]; + processor = createProcessor(); + + await positionStore.savePositionAsync( + '_processor_position', + const ProjectionPosition(lastProcessedSequence: 9, schemaHash: 'schema-from-processor'), + ); + + // Act. + final result = await processor.processBatchAsync(); + + // Assert: Projection positions should carry schema hash for rebuild detection. + expect(result.isSuccess, isTrue); + + final projectionPosition = await positionStore.loadPositionAsync('counter'); + expect(projectionPosition, isNotNull); + expect(projectionPosition!.schemaHash, equals('schema-from-processor')); + }); }); } @@ -184,14 +240,14 @@ void main() { int _eventCounter = 0; StoredEvent _createEvent(String streamId, {required int globalSequence}) { - return StoredEvent( - eventId: EventId('evt-${_eventCounter++}'), + final continuumEvent = _TestEvent(eventId: EventId('evt-${_eventCounter++}')); + + return StoredEvent.fromContinuumEvent( + continuumEvent: continuumEvent, streamId: StreamId(streamId), version: 0, eventType: 'test.event', - data: const {}, - occurredOn: DateTime.now(), - metadata: const {}, + data: const {}, globalSequence: globalSequence, ); } @@ -224,4 +280,39 @@ class _CounterProjection extends SingleStreamProjection<_CounterReadModel> { } } -class _TestEvent {} +final class _TestEvent implements ContinuumEvent { + _TestEvent({ + required EventId eventId, + DateTime? occurredOn, + Map metadata = const {}, + }) : id = eventId, + occurredOn = occurredOn ?? DateTime.now(), + metadata = Map.unmodifiable(metadata); + + @override + final EventId id; + + @override + final DateTime occurredOn; + + @override + final Map metadata; +} + +final class _FailingProjection extends SingleStreamProjection<_CounterReadModel> { + @override + Set get handledEventTypes => {_TestEvent}; + + @override + String get projectionName => 'failing'; + + @override + _CounterReadModel createInitial(StreamId streamId) { + return _CounterReadModel(streamId: streamId.value, count: 0); + } + + @override + _CounterReadModel apply(_CounterReadModel current, StoredEvent event) { + throw StateError('Intentional failure for testing'); + } +} From 04c8a1f2067f50090c667a67d0a8270917f53bf9 Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 22 Jan 2026 12:10:35 +0800 Subject: [PATCH 6/9] Added the bounded integration --- CHANGELOG.md | 2 + .../lib/abstract_interface_aggregates.dart | 49 ++--- .../example/lib/aggregate_creation.dart | 2 +- .../example/lib/aggregate_mutations.dart | 2 +- .../lib/domain/events/email_changed.dart | 3 +- .../lib/domain/events/user_deactivated.dart | 3 +- .../lib/domain/events/user_registered.dart | 11 +- .../continuum/example/lib/domain/user.dart | 13 +- .../continuum/example/lib/event_replay.dart | 2 +- .../example/lib/hybrid_multi_step_form.dart | 2 +- .../lib/hybrid_optimistic_creation.dart | 2 +- .../example/lib/hybrid_profile_edit.dart | 2 +- .../example/lib/projection_example.dart | 21 +- .../example/lib/store_atomic_rollback.dart | 4 +- .../example/lib/store_atomic_saves.dart | 4 +- .../example/lib/store_creating_streams.dart | 2 +- .../example/lib/store_handling_conflicts.dart | 2 +- .../lib/store_loading_and_updating.dart | 2 +- packages/continuum/example/pubspec.yaml | 3 +- packages/continuum/lib/continuum.dart | 5 +- .../lib/src/annotations/aggregate.dart | 15 -- .../lib/src/events/continuum_event.dart | 36 +-- .../lib/src/persistence/stored_event.dart | 2 +- packages/continuum/pubspec.yaml | 4 +- .../test/_fixtures/counter_fixtures.dart | 1 - .../test/events/continuum_event_test.dart | 1 - .../test/identity/stream_id_test.dart | 1 - .../persistence/dispatch_registries_test.dart | 1 - .../test/persistence/event_registry_test.dart | 1 - .../json_event_serializer_test.dart | 1 - .../test/persistence/session_test.dart | 1 - .../stored_event_domain_event_test.dart | 1 - .../stored_event_immutability_test.dart | 1 - .../test/persistence/stored_event_test.dart | 1 - .../async_projection_executor_test.dart | 1 - .../generated_projection_dispatch_test.dart | 1 - .../inline_projection_executor_test.dart | 1 - .../projection_processor_test.dart | 1 - .../test/projections/projection_test.dart | 1 - .../lib/src/aggregate_discovery.dart | 13 +- .../lib/src/combining_builder.dart | 10 +- .../lib/src/continuum_generator.dart | 3 +- packages/continuum_generator/pubspec.yaml | 2 + .../test/aggregate_discovery_test.dart | 207 ++++++++++++++---- .../test/code_emitter_test.dart | 64 ++++-- .../test/combining_builder_test.dart | 79 ++++--- .../generator_abstract_interface_test.dart | 190 ++++++++-------- .../example/lib/bad_audio_file.dart | 14 +- .../example/lib/good_audio_file.dart | 30 ++- packages/continuum_lints/example/pubspec.yaml | 2 + ...continuum_missing_apply_handlers_rule.dart | 8 +- ...inuum_missing_creation_factories_rule.dart | 10 +- ...continuum_required_creation_factories.dart | 2 +- packages/continuum_lints/pubspec.yaml | 1 + ...ement_missing_apply_handlers_fix_test.dart | 26 ++- ...t_missing_creation_factories_fix_test.dart | 11 +- ...ontinuum_required_apply_handlers_test.dart | 1 - ...t_creation_factories_integration_test.dart | 14 +- .../custom_lint_integration_test.dart | 11 +- .../example/lib/domain/account.dart | 20 +- .../lib/domain/events/account_opened.dart | 11 +- .../lib/domain/events/email_changed.dart | 5 +- .../lib/domain/events/funds_deposited.dart | 5 +- .../lib/domain/events/funds_withdrawn.dart | 5 +- .../lib/domain/events/user_deactivated.dart | 5 +- .../lib/domain/events/user_registered.dart | 14 +- .../example/lib/domain/user.dart | 30 ++- .../continuum_store_hive/example/main.dart | 4 +- .../continuum_store_hive/example/pubspec.yaml | 3 +- .../lib/src/hive_event_store.dart | 1 - packages/continuum_store_hive/pubspec.yaml | 2 +- .../test/hive_event_store_test.dart | 1 - .../example/lib/domain/account.dart | 16 +- .../lib/domain/events/account_opened.dart | 14 +- .../lib/domain/events/email_changed.dart | 5 +- .../lib/domain/events/funds_deposited.dart | 8 +- .../lib/domain/events/funds_withdrawn.dart | 5 +- .../lib/domain/events/user_deactivated.dart | 5 +- .../lib/domain/events/user_registered.dart | 15 +- .../example/lib/domain/user.dart | 30 ++- .../continuum_store_memory/example/main.dart | 4 +- .../example/pubspec.yaml | 5 +- packages/continuum_store_memory/pubspec.yaml | 3 +- .../test/in_memory_event_store_test.dart | 1 - 84 files changed, 685 insertions(+), 438 deletions(-) delete mode 100644 packages/continuum/lib/src/annotations/aggregate.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bff2b7..2be488a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - `continuum_missing_apply_handlers` no longer requires `apply(...)` handlers for creation events marked with `@AggregateEvent(creation: true)`. +- Fix `ContinuumEvent` contract so core compilation succeeds (restoring required `id`, `occurredOn`, and `metadata` fields). +- Fix `ContinuumEvent.metadata` typing to match examples/generator fixtures (`Map`). ## [4.0.0] - 2026-01-14 diff --git a/packages/continuum/example/lib/abstract_interface_aggregates.dart b/packages/continuum/example/lib/abstract_interface_aggregates.dart index 555a0cb..52a0919 100644 --- a/packages/continuum/example/lib/abstract_interface_aggregates.dart +++ b/packages/continuum/example/lib/abstract_interface_aggregates.dart @@ -5,8 +5,8 @@ /// `interface class`. library; +import 'package:bounded/bounded.dart'; import 'package:continuum/continuum.dart'; -import 'package:zooper_flutter_core/zooper_flutter_core.dart'; part 'abstract_interface_aggregates.g.dart'; @@ -25,7 +25,7 @@ void _runAbstractAggregateExample() { print('ABSTRACT AGGREGATE'); final user = AbstractUser( - id: 'abstract-user-1', + id: const AbstractUserId('abstract-user-1'), email: 'alice@example.com', name: 'Alice', ); @@ -43,10 +43,10 @@ void _runAbstractAggregateExample() { } void _runInterfaceAggregateExample() { - print('INTERFACE AGGREGATE'); + print('CONCRETE AGGREGATE'); - final user = ContractUser( - id: 'contract-user-1', + final user = UserContract( + id: const UserContractId('contract-user-1'), displayName: 'Bob', ); @@ -62,20 +62,22 @@ void _runInterfaceAggregateExample() { print(' ✓ Event dispatch works via UserContract'); } +final class AbstractUserId extends TypedIdentity { + const AbstractUserId(super.value); +} + /// An abstract aggregate base type. /// /// The generator produces: /// - `mixin _$AbstractUserBaseEventHandlers` /// - `extension $AbstractUserBaseEventDispatch on AbstractUserBase` -@Aggregate() -abstract class AbstractUserBase with _$AbstractUserBaseEventHandlers { +abstract class AbstractUserBase extends AggregateRoot with _$AbstractUserBaseEventHandlers { AbstractUserBase({ - required this.id, + required AbstractUserId id, required this.email, required this.name, - }); + }) : super(id); - final String id; String email; final String name; } @@ -94,28 +96,17 @@ class AbstractUser extends AbstractUserBase { } } -/// An interface aggregate type. -/// -/// The generator produces: -/// - `mixin _$UserContractEventHandlers` -/// - `extension $UserContractEventDispatch on UserContract` -@Aggregate() -abstract interface class UserContract with _$UserContractEventHandlers { - String get id; - String get displayName; +final class UserContractId extends TypedIdentity { + const UserContractId(super.value); } /// A concrete implementation of the interface aggregate. -class ContractUser with _$UserContractEventHandlers implements UserContract { - ContractUser({ - required this.id, +class UserContract extends AggregateRoot with _$UserContractEventHandlers { + UserContract({ + required UserContractId id, required this.displayName, - }); - - @override - final String id; + }) : super(id); - @override String displayName; @override @@ -158,7 +149,7 @@ class AbstractUserEmailChanged implements ContinuumEvent { Map toJson() => { 'newEmail': newEmail, - 'eventId': id.toString(), + 'eventId': id.value, 'occurredOn': occurredOn.toIso8601String(), 'metadata': metadata, }; @@ -198,7 +189,7 @@ class ContractUserRenamed implements ContinuumEvent { Map toJson() => { 'newDisplayName': newDisplayName, - 'eventId': id.toString(), + 'eventId': id.value, 'occurredOn': occurredOn.toIso8601String(), 'metadata': metadata, }; diff --git a/packages/continuum/example/lib/aggregate_creation.dart b/packages/continuum/example/lib/aggregate_creation.dart index 016c546..1e8139b 100644 --- a/packages/continuum/example/lib/aggregate_creation.dart +++ b/packages/continuum/example/lib/aggregate_creation.dart @@ -17,7 +17,7 @@ void main() { // The creation event captures all the data needed to initialize the aggregate. final user = User.createFromUserRegistered( UserRegistered( - userId: 'user-123', + userId: const UserId('user-123'), email: 'alice@example.com', name: 'Alice Smith', ), diff --git a/packages/continuum/example/lib/aggregate_mutations.dart b/packages/continuum/example/lib/aggregate_mutations.dart index 0401ffa..7b5995e 100644 --- a/packages/continuum/example/lib/aggregate_mutations.dart +++ b/packages/continuum/example/lib/aggregate_mutations.dart @@ -18,7 +18,7 @@ void main() { // Start with a user final user = User.createFromUserRegistered( UserRegistered( - userId: 'user-456', + userId: const UserId('user-456'), email: 'bob@example.com', name: 'Bob Johnson', ), diff --git a/packages/continuum/example/lib/domain/events/email_changed.dart b/packages/continuum/example/lib/domain/events/email_changed.dart index d402216..853c177 100644 --- a/packages/continuum/example/lib/domain/events/email_changed.dart +++ b/packages/continuum/example/lib/domain/events/email_changed.dart @@ -1,5 +1,4 @@ import 'package:continuum/continuum.dart'; -import 'package:zooper_flutter_core/zooper_flutter_core.dart'; import '../user.dart'; @@ -37,7 +36,7 @@ class EmailChanged implements ContinuumEvent { Map toJson() => { 'newEmail': newEmail, - 'eventId': id.toString(), + 'eventId': id.value, 'occurredOn': occurredOn.toIso8601String(), 'metadata': metadata, }; diff --git a/packages/continuum/example/lib/domain/events/user_deactivated.dart b/packages/continuum/example/lib/domain/events/user_deactivated.dart index 571378b..e676b2b 100644 --- a/packages/continuum/example/lib/domain/events/user_deactivated.dart +++ b/packages/continuum/example/lib/domain/events/user_deactivated.dart @@ -1,5 +1,4 @@ import 'package:continuum/continuum.dart'; -import 'package:zooper_flutter_core/zooper_flutter_core.dart'; import '../user.dart'; @@ -41,7 +40,7 @@ class UserDeactivated implements ContinuumEvent { Map toJson() => { 'deactivatedAt': deactivatedAt.toIso8601String(), 'reason': reason, - 'eventId': id.toString(), + 'eventId': id.value, 'occurredOn': occurredOn.toIso8601String(), 'metadata': metadata, }; diff --git a/packages/continuum/example/lib/domain/events/user_registered.dart b/packages/continuum/example/lib/domain/events/user_registered.dart index 73002bd..d5ea79b 100644 --- a/packages/continuum/example/lib/domain/events/user_registered.dart +++ b/packages/continuum/example/lib/domain/events/user_registered.dart @@ -1,11 +1,10 @@ import 'package:continuum/continuum.dart'; -import 'package:zooper_flutter_core/zooper_flutter_core.dart'; import '../user.dart'; /// Event fired when a new user registers. @AggregateEvent(of: User, type: 'user.registered', creation: true) -class UserRegistered implements ContinuumEvent { +final class UserRegistered implements ContinuumEvent { UserRegistered({ required this.userId, required this.email, @@ -17,7 +16,7 @@ class UserRegistered implements ContinuumEvent { occurredOn = occurredOn ?? DateTime.now(), metadata = Map.unmodifiable(metadata); - final String userId; + final UserId userId; final String email; final String name; @@ -32,7 +31,7 @@ class UserRegistered implements ContinuumEvent { factory UserRegistered.fromJson(Map json) { return UserRegistered( - userId: json['userId'] as String, + userId: UserId(json['userId'] as String), email: json['email'] as String, name: json['name'] as String, eventId: EventId.fromJson(json['eventId'] as String), @@ -42,10 +41,10 @@ class UserRegistered implements ContinuumEvent { } Map toJson() => { - 'userId': userId, + 'userId': userId.value, 'email': email, 'name': name, - 'eventId': id.toString(), + 'eventId': id.value, 'occurredOn': occurredOn.toIso8601String(), 'metadata': metadata, }; diff --git a/packages/continuum/example/lib/domain/user.dart b/packages/continuum/example/lib/domain/user.dart index e9bb252..534ded2 100644 --- a/packages/continuum/example/lib/domain/user.dart +++ b/packages/continuum/example/lib/domain/user.dart @@ -1,3 +1,4 @@ +import 'package:bounded/bounded.dart'; import 'package:continuum/continuum.dart'; import 'events/email_changed.dart'; @@ -6,25 +7,27 @@ import 'events/user_registered.dart'; part 'user.g.dart'; +final class UserId extends TypedIdentity { + const UserId(super.value); +} + /// A User aggregate demonstrating event sourcing. /// /// Users are created via registration, can update their email, /// and can be deactivated. Each state change is an event. -@Aggregate() -class User with _$UserEventHandlers { - final String id; +class User extends AggregateRoot with _$UserEventHandlers { String email; String name; bool isActive; DateTime? deactivatedAt; User._({ - required this.id, + required UserId id, required this.email, required this.name, required this.isActive, required this.deactivatedAt, - }); + }) : super(id); /// Creates a user from the registration event. static User createFromUserRegistered(UserRegistered event) { diff --git a/packages/continuum/example/lib/event_replay.dart b/packages/continuum/example/lib/event_replay.dart index 96ff86a..ff00899 100644 --- a/packages/continuum/example/lib/event_replay.dart +++ b/packages/continuum/example/lib/event_replay.dart @@ -23,7 +23,7 @@ void main() { print('Event history:'); final events = [ UserRegistered( - userId: 'user-789', + userId: const UserId('user-789'), email: 'carol@example.com', name: 'Carol White', ), diff --git a/packages/continuum/example/lib/hybrid_multi_step_form.dart b/packages/continuum/example/lib/hybrid_multi_step_form.dart index 7e42cc2..02a11e9 100644 --- a/packages/continuum/example/lib/hybrid_multi_step_form.dart +++ b/packages/continuum/example/lib/hybrid_multi_step_form.dart @@ -28,7 +28,7 @@ void main() { print('Step 1: User enters basic info'); final draftUser = User.createFromUserRegistered( UserRegistered( - userId: 'draft', + userId: const UserId('draft'), name: 'Draft User', email: 'step1@example.com', ), diff --git a/packages/continuum/example/lib/hybrid_optimistic_creation.dart b/packages/continuum/example/lib/hybrid_optimistic_creation.dart index 07f2ff6..21494c1 100644 --- a/packages/continuum/example/lib/hybrid_optimistic_creation.dart +++ b/packages/continuum/example/lib/hybrid_optimistic_creation.dart @@ -28,7 +28,7 @@ void main() async { print('Step 1: User fills out form'); final optimisticUser = User.createFromUserRegistered( UserRegistered( - userId: 'temp-new-user', + userId: const UserId('temp-new-user'), name: 'Jane Doe', email: 'jane@example.com', ), diff --git a/packages/continuum/example/lib/hybrid_profile_edit.dart b/packages/continuum/example/lib/hybrid_profile_edit.dart index 1f0cec9..45c8e61 100644 --- a/packages/continuum/example/lib/hybrid_profile_edit.dart +++ b/packages/continuum/example/lib/hybrid_profile_edit.dart @@ -24,7 +24,7 @@ void main() async { print('Initial state (loaded from backend):'); final existingUser = User.createFromUserRegistered( UserRegistered( - userId: 'user-456', + userId: const UserId('user-456'), name: 'Jane Doe', email: 'jane@example.com', ), diff --git a/packages/continuum/example/lib/projection_example.dart b/packages/continuum/example/lib/projection_example.dart index c9e1aa8..9beb6fd 100644 --- a/packages/continuum/example/lib/projection_example.dart +++ b/packages/continuum/example/lib/projection_example.dart @@ -43,7 +43,8 @@ void main() async { projections: registry, ); - final userId = const StreamId('user-123'); + final streamId = const StreamId('user-123'); + final userId = const UserId('user-123'); // --- Create User via Events --- print(''); @@ -51,9 +52,9 @@ void main() async { final session = store.openSession(); session.startStream( - userId, + streamId, UserRegistered( - userId: userId.value, + userId: userId, email: 'alice@example.com', name: 'Alice Smith', ), @@ -61,7 +62,7 @@ void main() async { await session.saveChangesAsync(); // Read profile from projection (inline = always up to date) - var profile = await profileStore.loadAsync(userId); + var profile = await profileStore.loadAsync(streamId); print(' Profile after registration: $profile'); // --- Update Email --- @@ -69,14 +70,14 @@ void main() async { print('Updating email...'); final updateSession = store.openSession(); - await updateSession.loadAsync(userId); + await updateSession.loadAsync(streamId); updateSession.append( - userId, + streamId, EmailChanged(newEmail: 'alice@company.com'), ); await updateSession.saveChangesAsync(); - profile = await profileStore.loadAsync(userId); + profile = await profileStore.loadAsync(streamId); print(' Profile after email change: $profile'); // --- Deactivate User --- @@ -84,14 +85,14 @@ void main() async { print('Deactivating user...'); final deactivateSession = store.openSession(); - await deactivateSession.loadAsync(userId); + await deactivateSession.loadAsync(streamId); deactivateSession.append( - userId, + streamId, UserDeactivated(deactivatedAt: DateTime.now()), ); await deactivateSession.saveChangesAsync(); - profile = await profileStore.loadAsync(userId); + profile = await profileStore.loadAsync(streamId); print(' Profile after deactivation: $profile'); // --- Summary --- diff --git a/packages/continuum/example/lib/store_atomic_rollback.dart b/packages/continuum/example/lib/store_atomic_rollback.dart index 7f7a9f6..ffd3acc 100644 --- a/packages/continuum/example/lib/store_atomic_rollback.dart +++ b/packages/continuum/example/lib/store_atomic_rollback.dart @@ -44,7 +44,7 @@ void main() async { session.startStream( userId1, UserRegistered( - userId: 'user-001', + userId: const UserId('user-001'), email: 'alice@example.com', name: 'Alice', ), @@ -52,7 +52,7 @@ void main() async { session.startStream( userId2, UserRegistered( - userId: 'user-002', + userId: const UserId('user-002'), email: 'bob@example.com', name: 'Bob', ), diff --git a/packages/continuum/example/lib/store_atomic_saves.dart b/packages/continuum/example/lib/store_atomic_saves.dart index caa2cd4..b44745e 100644 --- a/packages/continuum/example/lib/store_atomic_saves.dart +++ b/packages/continuum/example/lib/store_atomic_saves.dart @@ -44,7 +44,7 @@ void main() async { session.startStream( userId1, UserRegistered( - userId: 'user-001', + userId: const UserId('user-001'), email: 'alice@example.com', name: 'Alice', ), @@ -52,7 +52,7 @@ void main() async { session.startStream( userId2, UserRegistered( - userId: 'user-002', + userId: const UserId('user-002'), email: 'bob@example.com', name: 'Bob', ), diff --git a/packages/continuum/example/lib/store_creating_streams.dart b/packages/continuum/example/lib/store_creating_streams.dart index fc61220..ac801e4 100644 --- a/packages/continuum/example/lib/store_creating_streams.dart +++ b/packages/continuum/example/lib/store_creating_streams.dart @@ -45,7 +45,7 @@ void main() async { final user = session.startStream( userId, UserRegistered( - userId: 'user-001', + userId: const UserId('user-001'), email: 'alice@example.com', name: 'Alice Smith', ), diff --git a/packages/continuum/example/lib/store_handling_conflicts.dart b/packages/continuum/example/lib/store_handling_conflicts.dart index 603d641..d12631b 100644 --- a/packages/continuum/example/lib/store_handling_conflicts.dart +++ b/packages/continuum/example/lib/store_handling_conflicts.dart @@ -41,7 +41,7 @@ void main() async { session.startStream( userId, UserRegistered( - userId: 'user-001', + userId: const UserId('user-001'), email: 'bob@example.com', name: 'Bob Johnson', ), diff --git a/packages/continuum/example/lib/store_loading_and_updating.dart b/packages/continuum/example/lib/store_loading_and_updating.dart index 01a02b3..62b0c89 100644 --- a/packages/continuum/example/lib/store_loading_and_updating.dart +++ b/packages/continuum/example/lib/store_loading_and_updating.dart @@ -35,7 +35,7 @@ void main() async { session.startStream( userId, UserRegistered( - userId: 'user-001', + userId: const UserId('user-001'), email: 'alice@example.com', name: 'Alice Smith', ), diff --git a/packages/continuum/example/pubspec.yaml b/packages/continuum/example/pubspec.yaml index 7c243ea..1f6f7ec 100644 --- a/packages/continuum/example/pubspec.yaml +++ b/packages/continuum/example/pubspec.yaml @@ -12,7 +12,8 @@ dependencies: continuum_store_memory: path: ../../continuum_store_memory - zooper_flutter_core: ^1.0.3 + bounded: ^1.0.0 + zooper_flutter_core: ^2.0.0 dev_dependencies: build_runner: ^2.4.15 diff --git a/packages/continuum/lib/continuum.dart b/packages/continuum/lib/continuum.dart index 7c8c521..21192e1 100644 --- a/packages/continuum/lib/continuum.dart +++ b/packages/continuum/lib/continuum.dart @@ -4,8 +4,10 @@ /// event-sourced aggregates with code generation support. library; +// Re-export Ulid-based EventId from zooper_flutter_core +export 'package:zooper_flutter_core/zooper_flutter_core.dart' show EventId; + // Annotations for code generation discovery -export 'src/annotations/aggregate.dart'; export 'src/annotations/aggregate_event.dart'; export 'src/annotations/projection.dart'; @@ -51,4 +53,3 @@ export 'src/projections/projection_registry.dart'; export 'src/projections/read_model_result.dart'; export 'src/projections/read_model_store.dart'; export 'src/projections/single_stream_projection.dart'; - diff --git a/packages/continuum/lib/src/annotations/aggregate.dart b/packages/continuum/lib/src/annotations/aggregate.dart deleted file mode 100644 index d2afc68..0000000 --- a/packages/continuum/lib/src/annotations/aggregate.dart +++ /dev/null @@ -1,15 +0,0 @@ -/// Marks a class as an event-sourced aggregate root. -/// -/// When the generator scans the library, classes annotated with `@Aggregate()` -/// are treated as aggregate candidates for code generation. -/// -/// ```dart -/// @Aggregate() -/// class ShoppingCart with _$ShoppingCartEventHandlers { -/// // aggregate implementation -/// } -/// ``` -class Aggregate { - /// Creates an aggregate annotation. - const Aggregate(); -} diff --git a/packages/continuum/lib/src/events/continuum_event.dart b/packages/continuum/lib/src/events/continuum_event.dart index dfab220..6a85e90 100644 --- a/packages/continuum/lib/src/events/continuum_event.dart +++ b/packages/continuum/lib/src/events/continuum_event.dart @@ -1,3 +1,4 @@ +import 'package:bounded/bounded.dart'; import 'package:zooper_flutter_core/zooper_flutter_core.dart'; /// Base contract for all continuum events in an event-sourced system. @@ -6,34 +7,9 @@ import 'package:zooper_flutter_core/zooper_flutter_core.dart'; /// They are immutable and carry all the information needed to describe /// what occurred. /// -/// Implementations should provide their own constructors that accept -/// the event-specific data and optionally override [occurredOn] and -/// [metadata] defaults. +/// Continuum is compatible with pure unit-test usage (no event store needed). +/// Events are identified, timestamped, and can carry arbitrary metadata. /// -/// ```dart -/// @AggregateEvent(of: ShoppingCart, type: 'item_added') -/// class ItemAdded implements ContinuumEvent { -/// final String productId; -/// final int quantity; -/// -/// ItemAdded({ -/// required EventId eventId, -/// required this.productId, -/// required this.quantity, -/// DateTime? occurredOn, -/// Map metadata = const {}, -/// }) : id = eventId, -/// occurredOn = occurredOn ?? DateTime.now(), -/// metadata = Map.unmodifiable(metadata); -/// -/// @override -/// final EventId id; -/// -/// @override -/// final DateTime occurredOn; -/// -/// @override -/// final Map metadata; -/// } -/// ``` -abstract interface class ContinuumEvent implements ZooperDomainEvent {} +/// Implementations should ensure [id], [occurredOn], and [metadata] are +/// immutable. +abstract interface class ContinuumEvent implements BoundedDomainEvent {} diff --git a/packages/continuum/lib/src/persistence/stored_event.dart b/packages/continuum/lib/src/persistence/stored_event.dart index a2129b5..19c85f2 100644 --- a/packages/continuum/lib/src/persistence/stored_event.dart +++ b/packages/continuum/lib/src/persistence/stored_event.dart @@ -79,7 +79,7 @@ final class StoredEvent { eventType: eventType, data: data, occurredOn: continuumEvent.occurredOn, - metadata: continuumEvent.metadata, + metadata: Map.from(continuumEvent.metadata), globalSequence: globalSequence, domainEvent: continuumEvent, ); diff --git a/packages/continuum/pubspec.yaml b/packages/continuum/pubspec.yaml index 2ce1ca5..12c5049 100644 --- a/packages/continuum/pubspec.yaml +++ b/packages/continuum/pubspec.yaml @@ -10,7 +10,9 @@ environment: resolution: workspace dependencies: - zooper_flutter_core: ^1.0.3 + zooper_flutter_core: ^2.0.0 + + bounded: ^1.0.0 meta: ^1.15.0 diff --git a/packages/continuum/test/_fixtures/counter_fixtures.dart b/packages/continuum/test/_fixtures/counter_fixtures.dart index f7ed686..90f455f 100644 --- a/packages/continuum/test/_fixtures/counter_fixtures.dart +++ b/packages/continuum/test/_fixtures/counter_fixtures.dart @@ -1,5 +1,4 @@ import 'package:continuum/continuum.dart'; -import 'package:zooper_flutter_core/zooper_flutter_core.dart'; final class Counter { int value; diff --git a/packages/continuum/test/events/continuum_event_test.dart b/packages/continuum/test/events/continuum_event_test.dart index c0f3864..023316e 100644 --- a/packages/continuum/test/events/continuum_event_test.dart +++ b/packages/continuum/test/events/continuum_event_test.dart @@ -1,6 +1,5 @@ import 'package:continuum/continuum.dart'; import 'package:test/test.dart'; -import 'package:zooper_flutter_core/zooper_flutter_core.dart'; /// Test implementation of ContinuumEvent for testing purposes. final class TestEvent implements ContinuumEvent { diff --git a/packages/continuum/test/identity/stream_id_test.dart b/packages/continuum/test/identity/stream_id_test.dart index e04d2c6..c678979 100644 --- a/packages/continuum/test/identity/stream_id_test.dart +++ b/packages/continuum/test/identity/stream_id_test.dart @@ -1,6 +1,5 @@ import 'package:continuum/continuum.dart'; import 'package:test/test.dart'; -import 'package:zooper_flutter_core/zooper_flutter_core.dart'; void main() { group('StreamId', () { diff --git a/packages/continuum/test/persistence/dispatch_registries_test.dart b/packages/continuum/test/persistence/dispatch_registries_test.dart index 77ead28..815889e 100644 --- a/packages/continuum/test/persistence/dispatch_registries_test.dart +++ b/packages/continuum/test/persistence/dispatch_registries_test.dart @@ -1,6 +1,5 @@ import 'package:continuum/continuum.dart'; import 'package:test/test.dart'; -import 'package:zooper_flutter_core/zooper_flutter_core.dart'; import '../_fixtures/counter_fixtures.dart'; diff --git a/packages/continuum/test/persistence/event_registry_test.dart b/packages/continuum/test/persistence/event_registry_test.dart index e439966..0bf3bbe 100644 --- a/packages/continuum/test/persistence/event_registry_test.dart +++ b/packages/continuum/test/persistence/event_registry_test.dart @@ -1,6 +1,5 @@ import 'package:continuum/continuum.dart'; import 'package:test/test.dart'; -import 'package:zooper_flutter_core/zooper_flutter_core.dart'; /// Test event for registry testing. final class TestRegistryEvent implements ContinuumEvent { diff --git a/packages/continuum/test/persistence/json_event_serializer_test.dart b/packages/continuum/test/persistence/json_event_serializer_test.dart index e189877..187738a 100644 --- a/packages/continuum/test/persistence/json_event_serializer_test.dart +++ b/packages/continuum/test/persistence/json_event_serializer_test.dart @@ -1,6 +1,5 @@ import 'package:continuum/continuum.dart'; import 'package:test/test.dart'; -import 'package:zooper_flutter_core/zooper_flutter_core.dart'; final class ImmutableMapEvent implements ContinuumEvent { ImmutableMapEvent({ diff --git a/packages/continuum/test/persistence/session_test.dart b/packages/continuum/test/persistence/session_test.dart index c4e98df..27ccc09 100644 --- a/packages/continuum/test/persistence/session_test.dart +++ b/packages/continuum/test/persistence/session_test.dart @@ -2,7 +2,6 @@ import 'package:continuum/continuum.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:test/test.dart'; -import 'package:zooper_flutter_core/zooper_flutter_core.dart'; import '../_fixtures/counter_fixtures.dart'; diff --git a/packages/continuum/test/persistence/stored_event_domain_event_test.dart b/packages/continuum/test/persistence/stored_event_domain_event_test.dart index cb58ad7..b7cdfa3 100644 --- a/packages/continuum/test/persistence/stored_event_domain_event_test.dart +++ b/packages/continuum/test/persistence/stored_event_domain_event_test.dart @@ -1,6 +1,5 @@ import 'package:continuum/continuum.dart'; import 'package:test/test.dart'; -import 'package:zooper_flutter_core/zooper_flutter_core.dart'; void main() { group('StoredEvent.domainEvent', () { diff --git a/packages/continuum/test/persistence/stored_event_immutability_test.dart b/packages/continuum/test/persistence/stored_event_immutability_test.dart index a1a2761..99de5bb 100644 --- a/packages/continuum/test/persistence/stored_event_immutability_test.dart +++ b/packages/continuum/test/persistence/stored_event_immutability_test.dart @@ -1,6 +1,5 @@ import 'package:continuum/continuum.dart'; import 'package:test/test.dart'; -import 'package:zooper_flutter_core/zooper_flutter_core.dart'; void main() { group('StoredEvent immutability', () { diff --git a/packages/continuum/test/persistence/stored_event_test.dart b/packages/continuum/test/persistence/stored_event_test.dart index 40928d7..5e14993 100644 --- a/packages/continuum/test/persistence/stored_event_test.dart +++ b/packages/continuum/test/persistence/stored_event_test.dart @@ -1,6 +1,5 @@ import 'package:continuum/continuum.dart'; import 'package:test/test.dart'; -import 'package:zooper_flutter_core/zooper_flutter_core.dart'; /// Test event for stored event testing. final class TestStoredEvent implements ContinuumEvent { diff --git a/packages/continuum/test/projections/async_projection_executor_test.dart b/packages/continuum/test/projections/async_projection_executor_test.dart index 77521af..a0cac45 100644 --- a/packages/continuum/test/projections/async_projection_executor_test.dart +++ b/packages/continuum/test/projections/async_projection_executor_test.dart @@ -1,6 +1,5 @@ import 'package:continuum/continuum.dart'; import 'package:test/test.dart'; -import 'package:zooper_flutter_core/zooper_flutter_core.dart'; void main() { group('AsyncProjectionExecutor', () { diff --git a/packages/continuum/test/projections/generated_projection_dispatch_test.dart b/packages/continuum/test/projections/generated_projection_dispatch_test.dart index 4c29e05..4f983d1 100644 --- a/packages/continuum/test/projections/generated_projection_dispatch_test.dart +++ b/packages/continuum/test/projections/generated_projection_dispatch_test.dart @@ -1,6 +1,5 @@ import 'package:continuum/continuum.dart'; import 'package:test/test.dart'; -import 'package:zooper_flutter_core/zooper_flutter_core.dart'; void main() { group('Generated projection dispatch (domainEvent)', () { diff --git a/packages/continuum/test/projections/inline_projection_executor_test.dart b/packages/continuum/test/projections/inline_projection_executor_test.dart index 3fc556a..7e62df0 100644 --- a/packages/continuum/test/projections/inline_projection_executor_test.dart +++ b/packages/continuum/test/projections/inline_projection_executor_test.dart @@ -1,6 +1,5 @@ import 'package:continuum/continuum.dart'; import 'package:test/test.dart'; -import 'package:zooper_flutter_core/zooper_flutter_core.dart'; void main() { group('InlineProjectionExecutor', () { diff --git a/packages/continuum/test/projections/projection_processor_test.dart b/packages/continuum/test/projections/projection_processor_test.dart index 99838dc..ae825df 100644 --- a/packages/continuum/test/projections/projection_processor_test.dart +++ b/packages/continuum/test/projections/projection_processor_test.dart @@ -1,6 +1,5 @@ import 'package:continuum/continuum.dart'; import 'package:test/test.dart'; -import 'package:zooper_flutter_core/zooper_flutter_core.dart'; void main() { group('PollingProjectionProcessor', () { diff --git a/packages/continuum/test/projections/projection_test.dart b/packages/continuum/test/projections/projection_test.dart index fa2767a..8587018 100644 --- a/packages/continuum/test/projections/projection_test.dart +++ b/packages/continuum/test/projections/projection_test.dart @@ -1,6 +1,5 @@ import 'package:continuum/continuum.dart'; import 'package:test/test.dart'; -import 'package:zooper_flutter_core/zooper_flutter_core.dart'; void main() { group('Projection', () { diff --git a/packages/continuum_generator/lib/src/aggregate_discovery.dart b/packages/continuum_generator/lib/src/aggregate_discovery.dart index 19d47af..b652623 100644 --- a/packages/continuum_generator/lib/src/aggregate_discovery.dart +++ b/packages/continuum_generator/lib/src/aggregate_discovery.dart @@ -5,8 +5,8 @@ import 'package:source_gen/source_gen.dart'; import 'models/aggregate_info.dart'; import 'models/event_info.dart'; -/// Type checker for the @Aggregate annotation. -const _aggregateChecker = TypeChecker.fromUrl('package:continuum/src/annotations/aggregate.dart#Aggregate'); +/// Type checker for bounded's AggregateRoot base class. +const _aggregateRootChecker = TypeChecker.fromUrl('package:bounded/src/aggregate_root.dart#AggregateRoot'); /// Type checker for the @AggregateEvent annotation. const _eventChecker = TypeChecker.fromUrl('package:continuum/src/annotations/aggregate_event.dart#AggregateEvent'); @@ -16,7 +16,8 @@ const _continuumEventChecker = TypeChecker.fromUrl('package:continuum/src/events /// Discovers aggregates and events from library elements. /// -/// Scans a library for classes annotated with `@Aggregate()` and `@AggregateEvent()` +/// Scans a library for classes that extend/implement `AggregateRoot` and +/// events annotated with `@AggregateEvent()` /// and builds the mapping between aggregates and their events. /// /// Events can be defined in the same file OR in separate imported files. @@ -45,9 +46,11 @@ final class AggregateDiscovery { final aggregates = {}; final pendingEvents = []; - // First pass: discover all aggregates in THIS library + // First pass: discover all aggregates in THIS library. + // + // Aggregates are discovered by being assignable to bounded's AggregateRoot. for (final element in library.classes) { - if (_aggregateChecker.hasAnnotationOf(element)) { + if (_aggregateRootChecker.isAssignableFrom(element)) { final aggregateName = element.name ?? element.displayName; if (aggregateName.isEmpty) continue; aggregates[aggregateName] = AggregateInfo(element: element); diff --git a/packages/continuum_generator/lib/src/combining_builder.dart b/packages/continuum_generator/lib/src/combining_builder.dart index bebdcfb..23f5b83 100644 --- a/packages/continuum_generator/lib/src/combining_builder.dart +++ b/packages/continuum_generator/lib/src/combining_builder.dart @@ -13,7 +13,7 @@ const List _generatedDartFileSuffixesToIgnore = [ /// A builder that combines all discovered aggregates and projections into a single file. /// /// This builder runs after all per-aggregate and per-projection generators have completed. -/// It scans the entire package for `@Aggregate()` and `@Projection()` annotations and generates +/// It scans the entire package for bounded `AggregateRoot` types and `@Projection()` annotations and generates /// a single `lib/continuum.g.dart` file containing `$aggregateList` and `$projectionList`. /// /// Users can then simply write: @@ -30,8 +30,8 @@ const List _generatedDartFileSuffixesToIgnore = [ /// } /// ``` class CombiningBuilder implements Builder { - /// Type checker for the @Aggregate annotation. - static const _aggregateChecker = TypeChecker.fromUrl('package:continuum/src/annotations/aggregate.dart#Aggregate'); + /// Type checker for bounded's AggregateRoot base class. + static const _aggregateRootChecker = TypeChecker.fromUrl('package:bounded/src/aggregate_root.dart#AggregateRoot'); /// Type checker for the @Projection annotation. static const _projectionChecker = TypeChecker.fromUrl('package:continuum/src/annotations/projection.dart#Projection'); @@ -62,9 +62,9 @@ class CombiningBuilder implements Builder { // Calculate the import path relative to lib/. final importPath = input.path.replaceFirst('lib/', ''); - // Find all classes annotated with @Aggregate. + // Find all classes assignable to AggregateRoot. for (final element in library.classes) { - if (_aggregateChecker.hasAnnotationOf(element)) { + if (_aggregateRootChecker.isAssignableFrom(element)) { aggregateInfos.add( _DiscoveredInfo( className: element.displayName, diff --git a/packages/continuum_generator/lib/src/continuum_generator.dart b/packages/continuum_generator/lib/src/continuum_generator.dart index 226c13f..57d0d42 100644 --- a/packages/continuum_generator/lib/src/continuum_generator.dart +++ b/packages/continuum_generator/lib/src/continuum_generator.dart @@ -12,7 +12,8 @@ import 'projection_discovery.dart'; /// Generator for continuum event sourcing code. /// -/// Scans for @Aggregate, @AggregateEvent, and @Projection annotations and generates: +/// Scans for aggregate roots (types extending `bounded.AggregateRoot`) and for +/// `@AggregateEvent` / `@Projection` annotations and generates: /// - Event handler mixins for mutation events /// - Apply dispatchers and replay helpers /// - Creation dispatchers for aggregate instantiation diff --git a/packages/continuum_generator/pubspec.yaml b/packages/continuum_generator/pubspec.yaml index c8d156f..3db727b 100644 --- a/packages/continuum_generator/pubspec.yaml +++ b/packages/continuum_generator/pubspec.yaml @@ -12,6 +12,7 @@ resolution: workspace dependencies: analyzer: ^8.0.0 build: ^4.0.0 + bounded: ^1.0.0 continuum: ^4.0.1 source_gen: ^4.0.0 glob: ^2.1.3 @@ -21,5 +22,6 @@ dev_dependencies: build_test: ^3.5.4 custom_lint: ^0.8.1 lints: ^6.0.0 + package_config: ^2.1.0 source_gen_test: ^1.0.0 test: ^1.25.6 diff --git a/packages/continuum_generator/test/aggregate_discovery_test.dart b/packages/continuum_generator/test/aggregate_discovery_test.dart index 5f93615..836fba3 100644 --- a/packages/continuum_generator/test/aggregate_discovery_test.dart +++ b/packages/continuum_generator/test/aggregate_discovery_test.dart @@ -7,14 +7,19 @@ import 'package:test/test.dart'; void main() { group('AggregateDiscovery', () { - test('discovers abstract classes annotated with @Aggregate', () async { + test('discovers abstract aggregate roots', () async { // Arrange final inputs = { 'continuum_generator|lib/domain.dart': r""" -import 'package:continuum/src/annotations/aggregate.dart'; +import 'package:bounded/bounded.dart'; -@Aggregate() -abstract class UserBase {} +final class UserId extends TypedIdentity { + const UserId(super.value); +} + +abstract class UserBase extends AggregateRoot { + UserBase(super.id); +} """, }; @@ -37,20 +42,39 @@ abstract class UserBase {} expect(aggregates.single.name, 'UserBase'); }); - test('associates @AggregateEvent events to abstract aggregates', () async { + test('associates @AggregateEvent events to abstract aggregate roots', () async { // Arrange final inputs = { 'continuum_generator|lib/domain.dart': r""" -import 'package:continuum/src/annotations/aggregate.dart'; -import 'package:continuum/src/annotations/aggregate_event.dart'; -import 'package:continuum/src/events/continuum_event.dart'; +import 'package:bounded/bounded.dart'; +import 'package:continuum/continuum.dart'; -@Aggregate() -abstract class UserBase {} +final class UserId extends TypedIdentity { + const UserId(super.value); +} + +abstract class UserBase extends AggregateRoot { + UserBase(super.id); +} @AggregateEvent(of: UserBase) class EmailChanged implements ContinuumEvent { - const EmailChanged(); + EmailChanged({ + EventId? eventId, + DateTime? occurredOn, + Map metadata = const {}, + }) : id = eventId ?? EventId.fromUlid(), + occurredOn = occurredOn ?? DateTime(2020, 1, 1), + metadata = Map.unmodifiable(metadata); + + @override + final EventId id; + + @override + final DateTime occurredOn; + + @override + final Map metadata; } """, }; @@ -78,14 +102,19 @@ class EmailChanged implements ContinuumEvent { ); }); - test('discovers interface classes annotated with @Aggregate', () async { + test('discovers concrete aggregate roots', () async { // Arrange final inputs = { 'continuum_generator|lib/contracts.dart': r""" -import 'package:continuum/src/annotations/aggregate.dart'; +import 'package:bounded/bounded.dart'; -@Aggregate() -interface class UserContract {} +final class UserId extends TypedIdentity { + const UserId(super.value); +} + +class UserContract extends AggregateRoot { + UserContract(super.id); +} """, }; @@ -108,20 +137,39 @@ interface class UserContract {} expect(aggregates.single.name, 'UserContract'); }); - test('associates @AggregateEvent events to interface aggregates', () async { + test('associates @AggregateEvent events to concrete aggregate roots', () async { // Arrange final inputs = { 'continuum_generator|lib/contracts.dart': r""" -import 'package:continuum/src/annotations/aggregate.dart'; -import 'package:continuum/src/annotations/aggregate_event.dart'; -import 'package:continuum/src/events/continuum_event.dart'; +import 'package:bounded/bounded.dart'; +import 'package:continuum/continuum.dart'; -@Aggregate() -interface class UserContract {} +final class UserId extends TypedIdentity { + const UserId(super.value); +} + +class UserContract extends AggregateRoot { + UserContract(super.id); +} @AggregateEvent(of: UserContract) class UserRenamed implements ContinuumEvent { - const UserRenamed(); + UserRenamed({ + EventId? eventId, + DateTime? occurredOn, + Map metadata = const {}, + }) : id = eventId ?? EventId.fromUlid(), + occurredOn = occurredOn ?? DateTime(2020, 1, 1), + metadata = Map.unmodifiable(metadata); + + @override + final EventId id; + + @override + final DateTime occurredOn; + + @override + final Map metadata; } """, }; @@ -153,20 +201,39 @@ class UserRenamed implements ContinuumEvent { // Arrange final inputs = { 'continuum_generator|lib/audio_file.dart': r""" -import 'package:continuum/src/annotations/aggregate.dart'; +import 'package:bounded/bounded.dart'; -@Aggregate() -abstract class AudioFile {} +final class AudioFileId extends TypedIdentity { + const AudioFileId(super.value); +} + +abstract class AudioFile extends AggregateRoot { + AudioFile(super.id); +} """, 'continuum_generator|lib/audio_file_deleted_event.dart': r""" -import 'package:continuum/src/annotations/aggregate_event.dart'; -import 'package:continuum/src/events/continuum_event.dart'; +import 'package:continuum/continuum.dart'; import 'audio_file.dart'; @AggregateEvent(of: AudioFile) class AudioFileDeletedEvent implements ContinuumEvent { - const AudioFileDeletedEvent(); + AudioFileDeletedEvent({ + EventId? eventId, + DateTime? occurredOn, + Map metadata = const {}, + }) : id = eventId ?? EventId.fromUlid(), + occurredOn = occurredOn ?? DateTime(2020, 1, 1), + metadata = Map.unmodifiable(metadata); + + @override + final EventId id; + + @override + final DateTime occurredOn; + + @override + final Map metadata; } """, }; @@ -200,27 +267,62 @@ class AudioFileDeletedEvent implements ContinuumEvent { // Arrange final inputs = { 'continuum_generator|lib/domain.dart': r""" -import 'package:continuum/src/annotations/aggregate.dart'; -import 'package:continuum/src/annotations/aggregate_event.dart'; -import 'package:continuum/src/events/continuum_event.dart'; +import 'package:bounded/bounded.dart'; +import 'package:continuum/continuum.dart'; -@Aggregate() -class User { - const User(); +final class UserId extends TypedIdentity { + const UserId(super.value); +} + +class User extends AggregateRoot { + User(super.id); static User createFromUserRegistered(UserRegistered event) { - return const User(); + return User(event.userId); } } @AggregateEvent(of: User, creation: true) class UserRegistered implements ContinuumEvent { - const UserRegistered(); + UserRegistered( + this.userId, { + EventId? eventId, + DateTime? occurredOn, + Map metadata = const {}, + }) : id = eventId ?? EventId.fromUlid(), + occurredOn = occurredOn ?? DateTime(2020, 1, 1), + metadata = Map.unmodifiable(metadata); + + final UserId userId; + + @override + final EventId id; + + @override + final DateTime occurredOn; + + @override + final Map metadata; } @AggregateEvent(of: User) class UserRenamed implements ContinuumEvent { - const UserRenamed(); + UserRenamed({ + EventId? eventId, + DateTime? occurredOn, + Map metadata = const {}, + }) : id = eventId ?? EventId.fromUlid(), + occurredOn = occurredOn ?? DateTime(2020, 1, 1), + metadata = Map.unmodifiable(metadata); + + @override + final EventId id; + + @override + final DateTime occurredOn; + + @override + final Map metadata; } """, }; @@ -258,18 +360,35 @@ class UserRenamed implements ContinuumEvent { // Arrange final inputs = { 'continuum_generator|lib/domain.dart': r""" -import 'package:continuum/src/annotations/aggregate.dart'; -import 'package:continuum/src/annotations/aggregate_event.dart'; -import 'package:continuum/src/events/continuum_event.dart'; +import 'package:bounded/bounded.dart'; +import 'package:continuum/continuum.dart'; + +final class UserId extends TypedIdentity { + const UserId(super.value); +} -@Aggregate() -class User { - const User(); +class User extends AggregateRoot { + User(super.id); } @AggregateEvent(of: User, creation: true) class UserRegistered implements ContinuumEvent { - const UserRegistered(); + UserRegistered({ + EventId? eventId, + DateTime? occurredOn, + Map metadata = const {}, + }) : id = eventId ?? EventId.fromUlid(), + occurredOn = occurredOn ?? DateTime(2020, 1, 1), + metadata = Map.unmodifiable(metadata); + + @override + final EventId id; + + @override + final DateTime occurredOn; + + @override + final Map metadata; } """, }; diff --git a/packages/continuum_generator/test/code_emitter_test.dart b/packages/continuum_generator/test/code_emitter_test.dart index 702898d..473ac8d 100644 --- a/packages/continuum_generator/test/code_emitter_test.dart +++ b/packages/continuum_generator/test/code_emitter_test.dart @@ -11,16 +11,35 @@ void main() { // Arrange final inputs = { 'continuum_generator|lib/domain.dart': r""" - import 'package:continuum/src/annotations/aggregate.dart'; - import 'package:continuum/src/annotations/aggregate_event.dart'; - import 'package:continuum/src/events/continuum_event.dart'; + import 'package:bounded/bounded.dart'; + import 'package:continuum/continuum.dart'; -@Aggregate() -abstract class UserBase {} +final class UserId extends TypedIdentity { + const UserId(super.value); +} + +abstract class UserBase extends AggregateRoot { + UserBase(super.id); +} @AggregateEvent(of: UserBase) class EmailChanged implements ContinuumEvent { - const EmailChanged(); + EmailChanged({ + EventId? eventId, + DateTime? occurredOn, + Map metadata = const {}, + }) : id = eventId ?? EventId.fromUlid(), + occurredOn = occurredOn ?? DateTime(2020, 1, 1), + metadata = Map.unmodifiable(metadata); + + @override + final EventId id; + + @override + final DateTime occurredOn; + + @override + final Map metadata; } """, }; @@ -49,20 +68,39 @@ class EmailChanged implements ContinuumEvent { expect(output, contains('case EmailChanged():')); }); - test('emits apply dispatch for interface class', () async { + test('emits apply dispatch for concrete aggregate root', () async { // Arrange final inputs = { 'continuum_generator|lib/contracts.dart': r""" - import 'package:continuum/src/annotations/aggregate.dart'; - import 'package:continuum/src/annotations/aggregate_event.dart'; - import 'package:continuum/src/events/continuum_event.dart'; + import 'package:bounded/bounded.dart'; + import 'package:continuum/continuum.dart'; -@Aggregate() -interface class UserContract {} +final class UserId extends TypedIdentity { + const UserId(super.value); +} + +class UserContract extends AggregateRoot { + UserContract(super.id); +} @AggregateEvent(of: UserContract) class UserRenamed implements ContinuumEvent { - const UserRenamed(); + UserRenamed({ + EventId? eventId, + DateTime? occurredOn, + Map metadata = const {}, + }) : id = eventId ?? EventId.fromUlid(), + occurredOn = occurredOn ?? DateTime(2020, 1, 1), + metadata = Map.unmodifiable(metadata); + + @override + final EventId id; + + @override + final DateTime occurredOn; + + @override + final Map metadata; } """, }; diff --git a/packages/continuum_generator/test/combining_builder_test.dart b/packages/continuum_generator/test/combining_builder_test.dart index 103fdb0..b5d4ca2 100644 --- a/packages/continuum_generator/test/combining_builder_test.dart +++ b/packages/continuum_generator/test/combining_builder_test.dart @@ -1,22 +1,31 @@ +import 'dart:isolate'; + import 'package:build/build.dart'; import 'package:build_test/build_test.dart'; import 'package:continuum_generator/builder.dart'; +import 'package:package_config/package_config.dart'; import 'package:test/test.dart'; -const _aggregateAnnotationSource = ''' -/// Marks a class as an event-sourced aggregate root. -/// -/// When the generator scans the library, classes annotated with `@Aggregate()` -/// are treated as aggregate candidates for code generation. -class Aggregate { - /// Creates an aggregate annotation. - const Aggregate(); -} -'''; - void main() { + late final PackageConfig packageConfig; + late TestReaderWriter readerWriter; + + setUpAll(() async { + final uri = await Isolate.packageConfig; + if (uri == null) { + throw StateError('Missing Isolate.packageConfig; cannot run build_test in a pub workspace.'); + } + + packageConfig = await loadPackageConfigUri(uri); + }); + + setUp(() async { + readerWriter = TestReaderWriter(rootPackage: 'continuum_generator'); + await readerWriter.testing.loadIsolateSources(); + }); + group('CombiningBuilder', () { - test('generates lib/continuum.g.dart for @Aggregate classes', () async { + test('generates lib/continuum.g.dart for aggregate roots', () async { // Arrange: Provide a synthetic `$lib$` input and a single annotated // aggregate. This verifies the happy path where at least one aggregate // exists and a combining output should be produced. @@ -27,15 +36,21 @@ void main() { await testBuilder( builder, { - 'continuum|lib/src/annotations/aggregate.dart': _aggregateAnnotationSource, 'continuum_generator|lib/user.dart': """ -import 'package:continuum/src/annotations/aggregate.dart'; +import 'package:bounded/bounded.dart'; -@Aggregate() -class User {} +final class UserId extends TypedIdentity { + const UserId(super.value); +} + +class User extends AggregateRoot { + User(super.id); +} """, }, rootPackage: 'continuum_generator', + packageConfig: packageConfig, + readerWriter: readerWriter, outputs: { 'continuum_generator|lib/continuum.g.dart': decodedMatches( allOf( @@ -59,12 +74,16 @@ class User {} await testBuilder( builder, { - 'continuum|lib/src/annotations/aggregate.dart': _aggregateAnnotationSource, 'continuum_generator|lib/user.dart': """ -import 'package:continuum/src/annotations/aggregate.dart'; +import 'package:bounded/bounded.dart'; + +final class UserId extends TypedIdentity { + const UserId(super.value); +} -@Aggregate() -class User {} +class User extends AggregateRoot { + User(super.id); +} """, 'continuum_generator|lib/repository_error.dart': """ part 'repository_error.freezed.dart'; @@ -78,6 +97,8 @@ part of 'repository_error.dart'; """, }, rootPackage: 'continuum_generator', + packageConfig: packageConfig, + readerWriter: readerWriter, outputs: { 'continuum_generator|lib/continuum.g.dart': decodedMatches( allOf( @@ -98,12 +119,16 @@ part of 'repository_error.dart'; await testBuilder( builder, { - 'continuum|lib/src/annotations/aggregate.dart': _aggregateAnnotationSource, 'continuum_generator|lib/user.dart': """ -import 'package:continuum/src/annotations/aggregate.dart'; +import 'package:bounded/bounded.dart'; + +final class UserId extends TypedIdentity { + const UserId(super.value); +} -@Aggregate() -class User {} +class User extends AggregateRoot { + User(super.id); +} """, 'continuum_generator|lib/odd_library.dart': """ part 'odd_part.dart'; @@ -117,6 +142,8 @@ class OddPart {} """, }, rootPackage: 'continuum_generator', + packageConfig: packageConfig, + readerWriter: readerWriter, outputs: { 'continuum_generator|lib/continuum.g.dart': decodedMatches( contains(r' $User,'), @@ -126,7 +153,7 @@ class OddPart {} }); test('does not generate output when no aggregates exist', () async { - // Arrange: If no `@Aggregate()` classes exist, emitting an empty + // Arrange: If no aggregate roots exist, emitting an empty // `continuum.g.dart` would be surprising for users. final builder = continuumCombiningBuilder(const BuilderOptions({})); @@ -139,6 +166,8 @@ class NotAnAggregate {} ''', }, rootPackage: 'continuum_generator', + packageConfig: packageConfig, + readerWriter: readerWriter, outputs: const {}, ); }); diff --git a/packages/continuum_generator/test/generator_abstract_interface_test.dart b/packages/continuum_generator/test/generator_abstract_interface_test.dart index 0e10502..61fe6a6 100644 --- a/packages/continuum_generator/test/generator_abstract_interface_test.dart +++ b/packages/continuum_generator/test/generator_abstract_interface_test.dart @@ -1,82 +1,31 @@ +import 'dart:isolate'; + import 'package:build/build.dart'; import 'package:build_test/build_test.dart'; import 'package:continuum_generator/builder.dart'; +import 'package:package_config/package_config.dart'; import 'package:test/test.dart'; -const _aggregateAnnotationSource = ''' -class Aggregate { - const Aggregate(); -} -'''; - -const _aggregateEventAnnotationSource = ''' -class AggregateEvent { - final Type of; - final String? type; - - const AggregateEvent({required this.of, this.type}); -} -'''; - -const _continuumEventSource = ''' -abstract interface class ContinuumEvent {} -'''; - -const _continuumFacadeSource = ''' -library continuum; - -export 'src/events/continuum_event.dart'; +void main() { + late final PackageConfig packageConfig; + late TestReaderWriter readerWriter; -class GeneratedAggregate { - final EventSerializerRegistry serializerRegistry; - final AggregateFactoryRegistry aggregateFactories; - final EventApplierRegistry eventAppliers; + setUpAll(() async { + final uri = await Isolate.packageConfig; + if (uri == null) { + throw StateError('Missing Isolate.packageConfig; cannot run build_test in a pub workspace.'); + } - const GeneratedAggregate({ - required this.serializerRegistry, - required this.aggregateFactories, - required this.eventAppliers, + packageConfig = await loadPackageConfigUri(uri); }); -} - -class EventSerializerRegistry { - const EventSerializerRegistry(Map entries); -} -class EventSerializerEntry { - const EventSerializerEntry({ - required String eventType, - required Object? Function(Object event) toJson, - required Object? Function(Object json) fromJson, + setUp(() async { + readerWriter = TestReaderWriter(rootPackage: 'continuum_generator'); + await readerWriter.testing.loadIsolateSources(); }); -} - -class AggregateFactoryRegistry { - const AggregateFactoryRegistry(Map> factories); -} - -class EventApplierRegistry { - const EventApplierRegistry(Map> appliers); -} -class UnsupportedEventException implements Exception { - final Type eventType; - final Type aggregateType; - - UnsupportedEventException({required this.eventType, required this.aggregateType}); -} - -class InvalidCreationEventException implements Exception { - final Type eventType; - final Type aggregateType; - - InvalidCreationEventException({required this.eventType, required this.aggregateType}); -} -'''; - -void main() { group('ContinuumGenerator (abstract/interface)', () { - test('generates code for abstract @Aggregate and its events', () async { + test('generates code for abstract AggregateRoot and its events', () async { // Arrange final builder = continuumBuilder(const BuilderOptions({})); @@ -84,27 +33,44 @@ void main() { await testBuilder( builder, { - 'continuum|lib/src/annotations/aggregate.dart': _aggregateAnnotationSource, - 'continuum|lib/src/annotations/aggregate_event.dart': _aggregateEventAnnotationSource, - 'continuum|lib/src/events/continuum_event.dart': _continuumEventSource, - 'continuum|lib/continuum.dart': _continuumFacadeSource, 'continuum_generator|lib/domain.dart': r""" +import 'package:bounded/bounded.dart'; import 'package:continuum/continuum.dart'; -import 'package:continuum/src/annotations/aggregate.dart'; -import 'package:continuum/src/annotations/aggregate_event.dart'; part 'domain.continuum.g.dart'; -@Aggregate() -abstract class UserBase {} +final class UserId extends TypedIdentity { + const UserId(super.value); +} + +abstract class UserBase extends AggregateRoot { + UserBase(super.id); +} @AggregateEvent(of: UserBase) class EmailChanged implements ContinuumEvent { - const EmailChanged(); + EmailChanged({ + EventId? eventId, + DateTime? occurredOn, + Map metadata = const {}, + }) : id = eventId ?? EventId.fromUlid(), + occurredOn = occurredOn ?? DateTime(2020, 1, 1), + metadata = Map.unmodifiable(metadata); + + @override + final EventId id; + + @override + final DateTime occurredOn; + + @override + final Map metadata; } """, }, rootPackage: 'continuum_generator', + packageConfig: packageConfig, + readerWriter: readerWriter, outputs: { 'continuum_generator|lib/domain.continuum.g.part': decodedMatches( allOf( @@ -120,7 +86,7 @@ class EmailChanged implements ContinuumEvent { ); }); - test('generates code for interface @Aggregate and its events', () async { + test('generates code for concrete AggregateRoot and its events', () async { // Arrange final builder = continuumBuilder(const BuilderOptions({})); @@ -128,27 +94,44 @@ class EmailChanged implements ContinuumEvent { await testBuilder( builder, { - 'continuum|lib/src/annotations/aggregate.dart': _aggregateAnnotationSource, - 'continuum|lib/src/annotations/aggregate_event.dart': _aggregateEventAnnotationSource, - 'continuum|lib/src/events/continuum_event.dart': _continuumEventSource, - 'continuum|lib/continuum.dart': _continuumFacadeSource, 'continuum_generator|lib/contracts.dart': r""" +import 'package:bounded/bounded.dart'; import 'package:continuum/continuum.dart'; -import 'package:continuum/src/annotations/aggregate.dart'; -import 'package:continuum/src/annotations/aggregate_event.dart'; part 'contracts.continuum.g.dart'; -@Aggregate() -interface class UserContract {} +final class UserId extends TypedIdentity { + const UserId(super.value); +} + +class UserContract extends AggregateRoot { + UserContract(super.id); +} @AggregateEvent(of: UserContract) class UserRenamed implements ContinuumEvent { - const UserRenamed(); + UserRenamed({ + EventId? eventId, + DateTime? occurredOn, + Map metadata = const {}, + }) : id = eventId ?? EventId.fromUlid(), + occurredOn = occurredOn ?? DateTime(2020, 1, 1), + metadata = Map.unmodifiable(metadata); + + @override + final EventId id; + + @override + final DateTime occurredOn; + + @override + final Map metadata; } """, }, rootPackage: 'continuum_generator', + packageConfig: packageConfig, + readerWriter: readerWriter, outputs: { 'continuum_generator|lib/contracts.continuum.g.part': decodedMatches( allOf( @@ -172,34 +155,51 @@ class UserRenamed implements ContinuumEvent { await testBuilder( builder, { - 'continuum|lib/src/annotations/aggregate.dart': _aggregateAnnotationSource, - 'continuum|lib/src/annotations/aggregate_event.dart': _aggregateEventAnnotationSource, - 'continuum|lib/src/events/continuum_event.dart': _continuumEventSource, - 'continuum|lib/continuum.dart': _continuumFacadeSource, // IMPORTANT: Aggregate file does NOT import the event file. 'continuum_generator|lib/audio_file.dart': r""" +import 'package:bounded/bounded.dart'; import 'package:continuum/continuum.dart'; -import 'package:continuum/src/annotations/aggregate.dart'; part 'audio_file.continuum.g.dart'; -@Aggregate() -abstract class AudioFile {} +final class AudioFileId extends TypedIdentity { + const AudioFileId(super.value); +} + +abstract class AudioFile extends AggregateRoot { + AudioFile(super.id); +} """, // Event lives in a separate library and imports the aggregate instead. 'continuum_generator|lib/audio_file_deleted_event.dart': r""" import 'package:continuum/continuum.dart'; -import 'package:continuum/src/annotations/aggregate_event.dart'; import 'audio_file.dart'; @AggregateEvent(of: AudioFile) class AudioFileDeletedEvent implements ContinuumEvent { - const AudioFileDeletedEvent(); + AudioFileDeletedEvent({ + EventId? eventId, + DateTime? occurredOn, + Map metadata = const {}, + }) : id = eventId ?? EventId.fromUlid(), + occurredOn = occurredOn ?? DateTime(2020, 1, 1), + metadata = Map.unmodifiable(metadata); + + @override + final EventId id; + + @override + final DateTime occurredOn; + + @override + final Map metadata; } """, }, rootPackage: 'continuum_generator', + packageConfig: packageConfig, + readerWriter: readerWriter, outputs: { 'continuum_generator|lib/audio_file.continuum.g.part': decodedMatches( allOf( diff --git a/packages/continuum_lints/example/lib/bad_audio_file.dart b/packages/continuum_lints/example/lib/bad_audio_file.dart index 2921690..912089f 100644 --- a/packages/continuum_lints/example/lib/bad_audio_file.dart +++ b/packages/continuum_lints/example/lib/bad_audio_file.dart @@ -1,5 +1,12 @@ +import 'package:bounded/bounded.dart'; import 'package:continuum/continuum.dart'; +/// A strongly-typed identifier for an audio file aggregate. +final class AudioFileId extends TypedIdentity { + /// Creates an audio file identifier from a stable string value. + const AudioFileId(super.value); +} + /// Example creation event used by the missing-creation-factories lint. /// /// This event is explicitly marked as a creation event, which means the @@ -15,7 +22,7 @@ abstract class AudioFileCreated implements ContinuumEvent { /// This event intentionally has no generator annotations because the lint rule /// only cares about `apply(...)` handlers declared by the generated /// `_$EventHandlers` mixin. -sealed class AudioFileDeletedEvent implements ContinuumEvent { +abstract class AudioFileDeletedEvent implements ContinuumEvent { /// Creates a test event instance. const AudioFileDeletedEvent(); } @@ -37,11 +44,10 @@ mixin _$AudioFileEventHandlers { /// - `continuum_missing_creation_factories`: the [AudioFileCreated] event is /// marked as a creation event but the aggregate does not define /// `createFromAudioFileCreated(...)`. -@Aggregate() // ignore: continuum_missing_apply_handlers, continuum_missing_creation_factories -class AudioFile with _$AudioFileEventHandlers { +class AudioFile extends AggregateRoot with _$AudioFileEventHandlers { /// Creates an [AudioFile]. - const AudioFile(); + AudioFile(super.id); /// Implements `noSuchMethod` so the class can remain concrete even though it /// does not implement all interface members. diff --git a/packages/continuum_lints/example/lib/good_audio_file.dart b/packages/continuum_lints/example/lib/good_audio_file.dart index cc412f3..8991820 100644 --- a/packages/continuum_lints/example/lib/good_audio_file.dart +++ b/packages/continuum_lints/example/lib/good_audio_file.dart @@ -1,9 +1,28 @@ +import 'package:bounded/bounded.dart'; import 'package:continuum/continuum.dart'; +/// A strongly-typed identifier for an audio file aggregate. +final class AudioFileId extends TypedIdentity { + /// Creates an audio file identifier from a stable string value. + const AudioFileId(super.value); +} + /// Example event type used by the lint demonstration. -sealed class AudioFileDeletedEvent implements ContinuumEvent { - /// Creates a test event instance. - const AudioFileDeletedEvent(); +final class AudioFileDeletedEvent implements ContinuumEvent { + /// Creates an [AudioFileDeletedEvent]. + AudioFileDeletedEvent({EventId? eventId, DateTime? occurredOn, Map metadata = const {}}) + : id = eventId ?? EventId.fromUlid(), + occurredOn = occurredOn ?? DateTime.now(), + metadata = Map.unmodifiable(metadata); + + @override + final EventId id; + + @override + final DateTime occurredOn; + + @override + final Map metadata; } /// Mimics the mixin that would normally be generated by `continuum_generator`. @@ -13,10 +32,9 @@ mixin _$AudioFileEventHandlers { } /// Demonstrates the fixed variant: all required apply methods are implemented. -@Aggregate() -class AudioFile with _$AudioFileEventHandlers { +class AudioFile extends AggregateRoot with _$AudioFileEventHandlers { /// Creates an [AudioFile]. - const AudioFile(); + AudioFile(super.id); @override void applyAudioFileDeletedEvent(AudioFileDeletedEvent event) { diff --git a/packages/continuum_lints/example/pubspec.yaml b/packages/continuum_lints/example/pubspec.yaml index 45ee0be..b91ae6f 100644 --- a/packages/continuum_lints/example/pubspec.yaml +++ b/packages/continuum_lints/example/pubspec.yaml @@ -5,6 +5,8 @@ environment: sdk: ^3.10.4 dependencies: + zooper_flutter_core: ^2.0.0 + bounded: ^1.0.0 continuum: path: ../../continuum diff --git a/packages/continuum_lints/lib/src/continuum_missing_apply_handlers_rule.dart b/packages/continuum_lints/lib/src/continuum_missing_apply_handlers_rule.dart index 7e4c277..ec5e108 100644 --- a/packages/continuum_lints/lib/src/continuum_missing_apply_handlers_rule.dart +++ b/packages/continuum_lints/lib/src/continuum_missing_apply_handlers_rule.dart @@ -7,7 +7,7 @@ import 'package:custom_lint_builder/custom_lint_builder.dart'; import 'continuum_implement_missing_apply_handlers_fix.dart'; import 'continuum_required_apply_handlers.dart'; -/// Reports when a non-abstract `@Aggregate()` class is missing required +/// Reports when a non-abstract aggregate root is missing required /// `apply(...)` handlers declared by the generated /// `_$EventHandlers` mixin. /// @@ -20,12 +20,12 @@ import 'continuum_required_apply_handlers.dart'; final class ContinuumMissingApplyHandlersRule extends DartLintRule { static const LintCode _lintCode = LintCode( name: 'continuum_missing_apply_handlers', - problemMessage: 'This @Aggregate() class mixes in generated event handlers but is missing apply methods: {0}.', + problemMessage: 'This aggregate root mixes in generated event handlers but is missing apply methods: {0}.', correctionMessage: 'Implement the missing apply(...) methods.', errorSeverity: DiagnosticSeverity.WARNING, ); - static final TypeChecker _aggregateChecker = const TypeChecker.fromUrl('package:continuum/src/annotations/aggregate.dart#Aggregate'); + static final TypeChecker _aggregateRootChecker = const TypeChecker.fromUrl('package:bounded/src/aggregate_root.dart#AggregateRoot'); const ContinuumMissingApplyHandlersRule() : super(code: _lintCode); @@ -44,7 +44,7 @@ final class ContinuumMissingApplyHandlersRule extends DartLintRule { final ClassElement? classElement = node.declaredFragment?.element; if (classElement == null) return; - if (!_aggregateChecker.hasAnnotationOf(classElement)) return; + if (!_aggregateRootChecker.isAssignableFrom(classElement)) return; // If the user explicitly made the class abstract, they can defer handler // implementations to concrete subtypes. diff --git a/packages/continuum_lints/lib/src/continuum_missing_creation_factories_rule.dart b/packages/continuum_lints/lib/src/continuum_missing_creation_factories_rule.dart index a25e296..6993aad 100644 --- a/packages/continuum_lints/lib/src/continuum_missing_creation_factories_rule.dart +++ b/packages/continuum_lints/lib/src/continuum_missing_creation_factories_rule.dart @@ -7,7 +7,7 @@ import 'package:custom_lint_builder/custom_lint_builder.dart'; import 'continuum_implement_missing_creation_factories_fix.dart'; import 'continuum_required_creation_factories.dart'; -/// Reports when an `@Aggregate()` class is missing one or more required +/// Reports when an aggregate root is missing one or more required /// `createFrom(Event event)` creation factory methods for its creation /// events. /// @@ -19,14 +19,12 @@ import 'continuum_required_creation_factories.dart'; final class ContinuumMissingCreationFactoriesRule extends DartLintRule { static const LintCode _lintCode = LintCode( name: 'continuum_missing_creation_factories', - problemMessage: 'This @Aggregate() class is missing required creation factories: {0}.', + problemMessage: 'This aggregate root is missing required creation factories: {0}.', correctionMessage: 'Add the missing static createFrom(Event event) factory methods.', errorSeverity: DiagnosticSeverity.WARNING, ); - static final TypeChecker _aggregateChecker = const TypeChecker.fromUrl( - 'package:continuum/src/annotations/aggregate.dart#Aggregate', - ); + static final TypeChecker _aggregateRootChecker = const TypeChecker.fromUrl('package:bounded/src/aggregate_root.dart#AggregateRoot'); const ContinuumMissingCreationFactoriesRule() : super(code: _lintCode); @@ -45,7 +43,7 @@ final class ContinuumMissingCreationFactoriesRule extends DartLintRule { final ClassElement? classElement = node.declaredFragment?.element; if (classElement == null) return; - if (!_aggregateChecker.hasAnnotationOf(classElement)) return; + if (!_aggregateRootChecker.isAssignableFrom(classElement)) return; final List missingFactories = const ContinuumRequiredCreationFactories().findMissingCreationFactories(classElement); if (missingFactories.isEmpty) return; diff --git a/packages/continuum_lints/lib/src/continuum_required_creation_factories.dart b/packages/continuum_lints/lib/src/continuum_required_creation_factories.dart index 7aa6440..1d5824b 100644 --- a/packages/continuum_lints/lib/src/continuum_required_creation_factories.dart +++ b/packages/continuum_lints/lib/src/continuum_required_creation_factories.dart @@ -2,7 +2,7 @@ import 'package:analyzer/dart/element/element.dart'; import 'package:analyzer/dart/element/type.dart'; import 'package:custom_lint_builder/custom_lint_builder.dart'; -/// Computes which creation factory methods an `@Aggregate()` class is missing. +/// Computes which creation factory methods an aggregate root is missing. /// /// A creation event `E` for aggregate `A` requires a matching static factory: /// `static A createFromE(E event)`. diff --git a/packages/continuum_lints/pubspec.yaml b/packages/continuum_lints/pubspec.yaml index f47bc06..de69c37 100644 --- a/packages/continuum_lints/pubspec.yaml +++ b/packages/continuum_lints/pubspec.yaml @@ -15,6 +15,7 @@ dependencies: build: ^4.0.0 custom_lint_builder: ^0.8.1 path: ^1.9.0 + bounded: ^1.0.0 continuum: ^4.0.1 dev_dependencies: diff --git a/packages/continuum_lints/test/continuum_implement_missing_apply_handlers_fix_test.dart b/packages/continuum_lints/test/continuum_implement_missing_apply_handlers_fix_test.dart index 38374bd..5737855 100644 --- a/packages/continuum_lints/test/continuum_implement_missing_apply_handlers_fix_test.dart +++ b/packages/continuum_lints/test/continuum_implement_missing_apply_handlers_fix_test.dart @@ -22,9 +22,14 @@ void main() { final File dartFile = File('${tempDirectory.path}/domain.dart'); await dartFile.writeAsString(r''' +import 'package:bounded/bounded.dart'; import 'package:continuum/continuum.dart'; -sealed class AudioFileDeletedEvent implements ContinuumEvent { +final class AudioFileId extends TypedIdentity { + const AudioFileId(super.value); +} + +abstract class AudioFileDeletedEvent implements ContinuumEvent { const AudioFileDeletedEvent(); } @@ -32,9 +37,8 @@ mixin _$AudioFileEventHandlers { void applyAudioFileDeletedEvent(AudioFileDeletedEvent event); } -@Aggregate() -class AudioFile with _$AudioFileEventHandlers { - const AudioFile(); +class AudioFile extends AggregateRoot with _$AudioFileEventHandlers { + AudioFile(super.id); @override dynamic noSuchMethod(Invocation invocation) { @@ -95,14 +99,19 @@ class AudioFile with _$AudioFileEventHandlers { final File dartFile = File('${tempDirectory.path}/domain.dart'); await dartFile.writeAsString(r''' +import 'package:bounded/bounded.dart'; import 'package:continuum/continuum.dart'; +final class AudioFileId extends TypedIdentity { + const AudioFileId(super.value); +} + @AggregateEvent(of: AudioFile, creation: true) -class AudioFileCreatedEvent implements ContinuumEvent { +abstract class AudioFileCreatedEvent implements ContinuumEvent { const AudioFileCreatedEvent(); } -sealed class AudioFileDeletedEvent implements ContinuumEvent { +abstract class AudioFileDeletedEvent implements ContinuumEvent { const AudioFileDeletedEvent(); } @@ -111,9 +120,8 @@ mixin _$AudioFileEventHandlers { void applyAudioFileDeletedEvent(AudioFileDeletedEvent event); } -@Aggregate() -class AudioFile with _$AudioFileEventHandlers { - const AudioFile(); +class AudioFile extends AggregateRoot with _$AudioFileEventHandlers { + AudioFile(super.id); @override dynamic noSuchMethod(Invocation invocation) { diff --git a/packages/continuum_lints/test/continuum_implement_missing_creation_factories_fix_test.dart b/packages/continuum_lints/test/continuum_implement_missing_creation_factories_fix_test.dart index 51381a6..9c98705 100644 --- a/packages/continuum_lints/test/continuum_implement_missing_creation_factories_fix_test.dart +++ b/packages/continuum_lints/test/continuum_implement_missing_creation_factories_fix_test.dart @@ -22,12 +22,15 @@ void main() { final File dartFile = File('${tempDirectory.path}/domain.dart'); await dartFile.writeAsString(r''' +import 'package:bounded/bounded.dart'; import 'package:continuum/continuum.dart'; -import 'package:zooper_flutter_core/zooper_flutter_core.dart'; -@Aggregate() -class AudioFile { - const AudioFile(); +final class AudioFileId extends TypedIdentity { + const AudioFileId(super.value); +} + +class AudioFile extends AggregateRoot { + AudioFile(super.id); } @AggregateEvent(of: AudioFile, creation: true) diff --git a/packages/continuum_lints/test/continuum_required_apply_handlers_test.dart b/packages/continuum_lints/test/continuum_required_apply_handlers_test.dart index 2288f57..e093fe1 100644 --- a/packages/continuum_lints/test/continuum_required_apply_handlers_test.dart +++ b/packages/continuum_lints/test/continuum_required_apply_handlers_test.dart @@ -89,7 +89,6 @@ import 'package:continuum/continuum.dart'; part 'domain.g.dart'; -@Aggregate() class User with _$UserEventHandlers { const User(); } diff --git a/packages/continuum_lints/test/integration/custom_lint_creation_factories_integration_test.dart b/packages/continuum_lints/test/integration/custom_lint_creation_factories_integration_test.dart index 0f448f4..a366653 100644 --- a/packages/continuum_lints/test/integration/custom_lint_creation_factories_integration_test.dart +++ b/packages/continuum_lints/test/integration/custom_lint_creation_factories_integration_test.dart @@ -135,6 +135,7 @@ String _fixturePubspecYaml() { ' sdk: ">=3.10.0 <4.0.0"\n' '\n' 'dependencies:\n' + ' bounded: ^1.0.0\n' ' continuum:\n' ' path: ${_workspacePath('packages/continuum')}\n' '\n' @@ -164,13 +165,16 @@ String _fixtureCustomLintYaml() { String _fixtureMainDart() { return r'' + "import 'package:bounded/bounded.dart';\n" "import 'package:continuum/continuum.dart';\n" - "import 'package:zooper_flutter_core/zooper_flutter_core.dart';\n" '\n' - '@Aggregate()\n' - 'class AudioFile {\n' - ' const AudioFile();\n' - '}\n' + 'final class AudioFileId extends TypedIdentity {\n' + ' const AudioFileId(super.value);\n' + '}\n' + '\n' + 'class AudioFile extends AggregateRoot {\n' + ' AudioFile(super.id);\n' + '}\n' '\n' '@AggregateEvent(of: AudioFile, creation: true)\n' 'class AudioFileCreated implements ContinuumEvent {\n' diff --git a/packages/continuum_lints/test/integration/custom_lint_integration_test.dart b/packages/continuum_lints/test/integration/custom_lint_integration_test.dart index fa19364..1dec7fb 100644 --- a/packages/continuum_lints/test/integration/custom_lint_integration_test.dart +++ b/packages/continuum_lints/test/integration/custom_lint_integration_test.dart @@ -145,6 +145,7 @@ String _fixturePubspecYaml() { ' sdk: ">=3.10.0 <4.0.0"\n' '\n' 'dependencies:\n' + ' bounded: ^1.0.0\n' ' continuum:\n' ' path: ${_workspacePath('packages/continuum')}\n' '\n' @@ -176,14 +177,20 @@ String _fixtureMainDart() { // The mixin name must match the generator convention: _$EventHandlers. // We declare it manually so that the test doesn't need build_runner. return r'' + "import 'package:bounded/bounded.dart';\n" "import 'package:continuum/continuum.dart';\n" '\n' + 'final class AudioFileId extends TypedIdentity {\n' + ' const AudioFileId(super.value);\n' + '}\n' + '\n' 'mixin _\$AudioFileEventHandlers {\n' ' void applyAudioFileDeletedEvent();\n' '}\n' '\n' - '@Aggregate()\n' - 'class AudioFile with _\$AudioFileEventHandlers {}\n'; + 'class AudioFile extends AggregateRoot with _\$AudioFileEventHandlers {\n' + ' AudioFile(super.id);\n' + '}\n'; } String _workspacePath(String relativeWorkspacePath) { diff --git a/packages/continuum_store_hive/example/lib/domain/account.dart b/packages/continuum_store_hive/example/lib/domain/account.dart index 660d003..7ca1d2a 100644 --- a/packages/continuum_store_hive/example/lib/domain/account.dart +++ b/packages/continuum_store_hive/example/lib/domain/account.dart @@ -1,3 +1,4 @@ +import 'package:bounded/bounded.dart'; import 'package:continuum/continuum.dart'; import 'events/account_opened.dart'; @@ -10,20 +11,21 @@ export 'events/funds_withdrawn.dart'; part 'account.g.dart'; +/// A strongly-typed account identifier. +final class AccountId extends TypedIdentity { + /// Creates an account identifier from a stable string value. + const AccountId(super.value); +} + /// A bank Account aggregate demonstrating multiple aggregates in one project. -@Aggregate() -class Account with _$AccountEventHandlers { - final String id; +class Account extends AggregateRoot with _$AccountEventHandlers { final String ownerId; int balance; - Account._({ - required this.id, - required this.ownerId, - required this.balance, - }); + Account._({required AccountId id, required this.ownerId, required this.balance}) : super(id); static Account createFromAccountOpened(AccountOpened event) { + // WHY: Creation events define initial state for replay. return Account._( id: event.accountId, ownerId: event.ownerId, @@ -33,11 +35,13 @@ class Account with _$AccountEventHandlers { @override void applyFundsDeposited(FundsDeposited event) { + // WHY: Balance is derived from event history. balance += event.amount; } @override void applyFundsWithdrawn(FundsWithdrawn event) { + // WHY: Balance is derived from event history. balance -= event.amount; } } diff --git a/packages/continuum_store_hive/example/lib/domain/events/account_opened.dart b/packages/continuum_store_hive/example/lib/domain/events/account_opened.dart index 059a054..a34876a 100644 --- a/packages/continuum_store_hive/example/lib/domain/events/account_opened.dart +++ b/packages/continuum_store_hive/example/lib/domain/events/account_opened.dart @@ -1,5 +1,4 @@ import 'package:continuum/continuum.dart'; -import 'package:zooper_flutter_core/zooper_flutter_core.dart'; import '../account.dart'; @@ -18,13 +17,15 @@ class AccountOpened implements ContinuumEvent { factory AccountOpened.fromJson(Map json) { return AccountOpened( - eventId: EventId(json['eventId'] as String), - accountId: json['accountId'] as String, + eventId: EventId.fromJson(json['eventId'] as String), + occurredOn: DateTime.parse(json['occurredOn'] as String), + metadata: Map.from(json['metadata'] as Map), + accountId: AccountId(json['accountId'] as String), ownerId: json['ownerId'] as String, ); } - final String accountId; + final AccountId accountId; final String ownerId; @override @@ -37,7 +38,7 @@ class AccountOpened implements ContinuumEvent { final Map metadata; Map toJson() => { - 'accountId': accountId, + 'accountId': accountId.value, 'ownerId': ownerId, }; } diff --git a/packages/continuum_store_hive/example/lib/domain/events/email_changed.dart b/packages/continuum_store_hive/example/lib/domain/events/email_changed.dart index b7aa979..83d09d9 100644 --- a/packages/continuum_store_hive/example/lib/domain/events/email_changed.dart +++ b/packages/continuum_store_hive/example/lib/domain/events/email_changed.dart @@ -1,5 +1,4 @@ import 'package:continuum/continuum.dart'; -import 'package:zooper_flutter_core/zooper_flutter_core.dart'; import '../user.dart'; @@ -28,7 +27,9 @@ class EmailChanged implements ContinuumEvent { factory EmailChanged.fromJson(Map json) { return EmailChanged( - eventId: EventId(json['eventId'] as String), + eventId: EventId.fromJson(json['eventId'] as String), + occurredOn: DateTime.parse(json['occurredOn'] as String), + metadata: Map.from(json['metadata'] as Map), newEmail: json['newEmail'] as String, ); } diff --git a/packages/continuum_store_hive/example/lib/domain/events/funds_deposited.dart b/packages/continuum_store_hive/example/lib/domain/events/funds_deposited.dart index 97960cd..e390039 100644 --- a/packages/continuum_store_hive/example/lib/domain/events/funds_deposited.dart +++ b/packages/continuum_store_hive/example/lib/domain/events/funds_deposited.dart @@ -1,5 +1,4 @@ import 'package:continuum/continuum.dart'; -import 'package:zooper_flutter_core/zooper_flutter_core.dart'; import '../account.dart'; @@ -28,7 +27,9 @@ class FundsDeposited implements ContinuumEvent { factory FundsDeposited.fromJson(Map json) { return FundsDeposited( - eventId: EventId(json['eventId'] as String), + eventId: EventId.fromJson(json['eventId'] as String), + occurredOn: DateTime.parse(json['occurredOn'] as String), + metadata: Map.from(json['metadata'] as Map), amount: json['amount'] as int, ); } diff --git a/packages/continuum_store_hive/example/lib/domain/events/funds_withdrawn.dart b/packages/continuum_store_hive/example/lib/domain/events/funds_withdrawn.dart index b1de20d..6f5642c 100644 --- a/packages/continuum_store_hive/example/lib/domain/events/funds_withdrawn.dart +++ b/packages/continuum_store_hive/example/lib/domain/events/funds_withdrawn.dart @@ -1,5 +1,4 @@ import 'package:continuum/continuum.dart'; -import 'package:zooper_flutter_core/zooper_flutter_core.dart'; import '../account.dart'; @@ -28,7 +27,9 @@ class FundsWithdrawn implements ContinuumEvent { factory FundsWithdrawn.fromJson(Map json) { return FundsWithdrawn( - eventId: EventId(json['eventId'] as String), + eventId: EventId.fromJson(json['eventId'] as String), + occurredOn: DateTime.parse(json['occurredOn'] as String), + metadata: Map.from(json['metadata'] as Map), amount: json['amount'] as int, ); } diff --git a/packages/continuum_store_hive/example/lib/domain/events/user_deactivated.dart b/packages/continuum_store_hive/example/lib/domain/events/user_deactivated.dart index 8d4674e..2182d58 100644 --- a/packages/continuum_store_hive/example/lib/domain/events/user_deactivated.dart +++ b/packages/continuum_store_hive/example/lib/domain/events/user_deactivated.dart @@ -1,5 +1,4 @@ import 'package:continuum/continuum.dart'; -import 'package:zooper_flutter_core/zooper_flutter_core.dart'; import '../user.dart'; @@ -30,7 +29,9 @@ class UserDeactivated implements ContinuumEvent { factory UserDeactivated.fromJson(Map json) { return UserDeactivated( - eventId: EventId(json['eventId'] as String), + eventId: EventId.fromJson(json['eventId'] as String), + occurredOn: DateTime.parse(json['occurredOn'] as String), + metadata: Map.from(json['metadata'] as Map), deactivatedAt: DateTime.parse(json['deactivatedAt'] as String), reason: json['reason'] as String?, ); diff --git a/packages/continuum_store_hive/example/lib/domain/events/user_registered.dart b/packages/continuum_store_hive/example/lib/domain/events/user_registered.dart index 8d8ee94..33f43d2 100644 --- a/packages/continuum_store_hive/example/lib/domain/events/user_registered.dart +++ b/packages/continuum_store_hive/example/lib/domain/events/user_registered.dart @@ -1,5 +1,4 @@ import 'package:continuum/continuum.dart'; -import 'package:zooper_flutter_core/zooper_flutter_core.dart'; import '../user.dart'; @@ -19,16 +18,16 @@ class UserRegistered implements ContinuumEvent { factory UserRegistered.fromJson(Map json) { return UserRegistered( - userId: json['userId'] as String, + userId: UserId(json['userId'] as String), email: json['email'] as String, name: json['name'] as String, - eventId: EventId(json['eventId'] as String), + eventId: EventId.fromJson(json['eventId'] as String), occurredOn: DateTime.parse(json['occurredOn'] as String), - metadata: Map.unmodifiable(json['metadata'] as Map), + metadata: Map.from(json['metadata'] as Map), ); } - final String userId; + final UserId userId; final String email; final String name; @@ -42,11 +41,8 @@ class UserRegistered implements ContinuumEvent { final Map metadata; Map toJson() => { - 'userId': userId, + 'userId': userId.value, 'email': email, 'name': name, - 'eventId': id, - 'occurredOn': occurredOn.toIso8601String(), - 'metadata': metadata, }; } diff --git a/packages/continuum_store_hive/example/lib/domain/user.dart b/packages/continuum_store_hive/example/lib/domain/user.dart index d7ed842..dc5b947 100644 --- a/packages/continuum_store_hive/example/lib/domain/user.dart +++ b/packages/continuum_store_hive/example/lib/domain/user.dart @@ -1,3 +1,4 @@ +import 'package:bounded/bounded.dart'; import 'package:continuum/continuum.dart'; import 'events/email_changed.dart'; @@ -10,28 +11,47 @@ export 'events/user_registered.dart'; part 'user.g.dart'; +/// A strongly-typed user identifier. +final class UserId extends TypedIdentity { + /// Creates a user identifier from a stable string value. + const UserId(super.value); +} + /// A User aggregate demonstrating event sourcing with Hive persistence. -@Aggregate() -class User with _$UserEventHandlers { - final String id; +class User extends AggregateRoot with _$UserEventHandlers { String email; String name; bool isActive; DateTime? deactivatedAt; - User._({required this.id, required this.email, required this.name, required this.isActive, required this.deactivatedAt}); + User._({ + required UserId id, + required this.email, + required this.name, + required this.isActive, + required this.deactivatedAt, + }) : super(id); static User createFromUserRegistered(UserRegistered event) { - return User._(id: event.userId, email: event.email, name: event.name, isActive: true, deactivatedAt: null); + // WHY: Creation events define initial state for replay. + return User._( + id: event.userId, + email: event.email, + name: event.name, + isActive: true, + deactivatedAt: null, + ); } @override void applyEmailChanged(EmailChanged event) { + // WHY: Mutation events update state during replay and live operations. email = event.newEmail; } @override void applyUserDeactivated(UserDeactivated event) { + // WHY: Deactivation is a state transition derived from events. isActive = false; deactivatedAt = event.deactivatedAt; } diff --git a/packages/continuum_store_hive/example/main.dart b/packages/continuum_store_hive/example/main.dart index d0acac0..bf70010 100644 --- a/packages/continuum_store_hive/example/main.dart +++ b/packages/continuum_store_hive/example/main.dart @@ -40,7 +40,7 @@ void main() async { final hiveStore = await HiveEventStore.openAsync(boxName: 'events'); final store = EventSourcingStore( eventStore: hiveStore, - aggregates: $aggregateList, // Auto-generated from @Aggregate classes + aggregates: $aggregateList, // Auto-generated from AggregateRoot classes ); print('Creating a user...'); @@ -50,7 +50,7 @@ void main() async { final user = session.startStream( userId, UserRegistered( - userId: 'user-001', + userId: const UserId('user-001'), email: 'alice@example.com', name: 'Alice', ), diff --git a/packages/continuum_store_hive/example/pubspec.yaml b/packages/continuum_store_hive/example/pubspec.yaml index 8036c81..2ee9d6c 100644 --- a/packages/continuum_store_hive/example/pubspec.yaml +++ b/packages/continuum_store_hive/example/pubspec.yaml @@ -7,12 +7,13 @@ environment: sdk: ^3.10.4 dependencies: + zooper_flutter_core: ^2.0.0 + bounded: ^1.0.0 continuum: path: ../../continuum continuum_store_hive: path: ../ hive: ^2.2.3 - zooper_flutter_core: ^1.0.3 dev_dependencies: build_runner: ^2.4.15 diff --git a/packages/continuum_store_hive/lib/src/hive_event_store.dart b/packages/continuum_store_hive/lib/src/hive_event_store.dart index 44d7a01..791e735 100644 --- a/packages/continuum_store_hive/lib/src/hive_event_store.dart +++ b/packages/continuum_store_hive/lib/src/hive_event_store.dart @@ -3,7 +3,6 @@ import 'dart:convert'; import 'package:continuum/continuum.dart'; import 'package:hive/hive.dart'; -import 'package:zooper_flutter_core/zooper_flutter_core.dart'; /// Hive-backed implementation of [EventStore]. /// diff --git a/packages/continuum_store_hive/pubspec.yaml b/packages/continuum_store_hive/pubspec.yaml index 93268c2..74c1719 100644 --- a/packages/continuum_store_hive/pubspec.yaml +++ b/packages/continuum_store_hive/pubspec.yaml @@ -10,9 +10,9 @@ environment: resolution: workspace dependencies: + zooper_flutter_core: ^2.0.0 continuum: ^4.0.1 hive: ^2.2.3 - zooper_flutter_core: ^1.0.3 dev_dependencies: build_runner: ^2.4.0 diff --git a/packages/continuum_store_hive/test/hive_event_store_test.dart b/packages/continuum_store_hive/test/hive_event_store_test.dart index e2d9c88..2b0489e 100644 --- a/packages/continuum_store_hive/test/hive_event_store_test.dart +++ b/packages/continuum_store_hive/test/hive_event_store_test.dart @@ -4,7 +4,6 @@ import 'package:continuum/continuum.dart'; import 'package:continuum_store_hive/continuum_store_hive.dart'; import 'package:hive/hive.dart'; import 'package:test/test.dart'; -import 'package:zooper_flutter_core/zooper_flutter_core.dart'; void main() { late Directory tempDir; diff --git a/packages/continuum_store_memory/example/lib/domain/account.dart b/packages/continuum_store_memory/example/lib/domain/account.dart index 2fa5245..5ba91be 100644 --- a/packages/continuum_store_memory/example/lib/domain/account.dart +++ b/packages/continuum_store_memory/example/lib/domain/account.dart @@ -1,3 +1,4 @@ +import 'package:bounded/bounded.dart'; import 'package:continuum/continuum.dart'; import 'events/account_opened.dart'; @@ -10,26 +11,33 @@ export 'events/funds_withdrawn.dart'; part 'account.g.dart'; +/// A strongly-typed account identifier. +final class AccountId extends TypedIdentity { + /// Creates an account identifier from a stable string value. + const AccountId(super.value); +} + /// A bank Account aggregate demonstrating multiple aggregates in one project. -@Aggregate() -class Account with _$AccountEventHandlers { - final String id; +class Account extends AggregateRoot with _$AccountEventHandlers { final String ownerId; int balance; - Account._({required this.id, required this.ownerId, required this.balance}); + Account._({required AccountId id, required this.ownerId, required this.balance}) : super(id); static Account createFromAccountOpened(AccountOpened event) { + // WHY: Creation events establish initial state for replay. return Account._(id: event.accountId, ownerId: event.ownerId, balance: 0); } @override void applyFundsDeposited(FundsDeposited event) { + // WHY: Balance is derived from event history. balance += event.amount; } @override void applyFundsWithdrawn(FundsWithdrawn event) { + // WHY: Balance is derived from event history. balance -= event.amount; } } diff --git a/packages/continuum_store_memory/example/lib/domain/events/account_opened.dart b/packages/continuum_store_memory/example/lib/domain/events/account_opened.dart index 172114f..a34876a 100644 --- a/packages/continuum_store_memory/example/lib/domain/events/account_opened.dart +++ b/packages/continuum_store_memory/example/lib/domain/events/account_opened.dart @@ -1,5 +1,4 @@ import 'package:continuum/continuum.dart'; -import 'package:zooper_flutter_core/zooper_flutter_core.dart'; import '../account.dart'; @@ -18,13 +17,15 @@ class AccountOpened implements ContinuumEvent { factory AccountOpened.fromJson(Map json) { return AccountOpened( - eventId: EventId(json['eventId'] as String), - accountId: json['accountId'] as String, + eventId: EventId.fromJson(json['eventId'] as String), + occurredOn: DateTime.parse(json['occurredOn'] as String), + metadata: Map.from(json['metadata'] as Map), + accountId: AccountId(json['accountId'] as String), ownerId: json['ownerId'] as String, ); } - final String accountId; + final AccountId accountId; final String ownerId; @override @@ -36,5 +37,8 @@ class AccountOpened implements ContinuumEvent { @override final Map metadata; - Map toJson() => {'accountId': accountId, 'ownerId': ownerId}; + Map toJson() => { + 'accountId': accountId.value, + 'ownerId': ownerId, + }; } diff --git a/packages/continuum_store_memory/example/lib/domain/events/email_changed.dart b/packages/continuum_store_memory/example/lib/domain/events/email_changed.dart index dc9645a..f3e2447 100644 --- a/packages/continuum_store_memory/example/lib/domain/events/email_changed.dart +++ b/packages/continuum_store_memory/example/lib/domain/events/email_changed.dart @@ -1,5 +1,4 @@ import 'package:continuum/continuum.dart'; -import 'package:zooper_flutter_core/zooper_flutter_core.dart'; import '../user.dart'; @@ -28,7 +27,9 @@ class EmailChanged implements ContinuumEvent { factory EmailChanged.fromJson(Map json) { return EmailChanged( - eventId: EventId(json['eventId'] as String), + eventId: EventId.fromJson(json['eventId'] as String), + occurredOn: DateTime.parse(json['occurredOn'] as String), + metadata: Map.from(json['metadata'] as Map), newEmail: json['newEmail'] as String, ); } diff --git a/packages/continuum_store_memory/example/lib/domain/events/funds_deposited.dart b/packages/continuum_store_memory/example/lib/domain/events/funds_deposited.dart index 804ea1a..e390039 100644 --- a/packages/continuum_store_memory/example/lib/domain/events/funds_deposited.dart +++ b/packages/continuum_store_memory/example/lib/domain/events/funds_deposited.dart @@ -1,5 +1,4 @@ import 'package:continuum/continuum.dart'; -import 'package:zooper_flutter_core/zooper_flutter_core.dart'; import '../account.dart'; @@ -27,7 +26,12 @@ class FundsDeposited implements ContinuumEvent { final Map metadata; factory FundsDeposited.fromJson(Map json) { - return FundsDeposited(eventId: EventId(json['eventId'] as String), amount: json['amount'] as int); + return FundsDeposited( + eventId: EventId.fromJson(json['eventId'] as String), + occurredOn: DateTime.parse(json['occurredOn'] as String), + metadata: Map.from(json['metadata'] as Map), + amount: json['amount'] as int, + ); } Map toJson() => {'amount': amount}; diff --git a/packages/continuum_store_memory/example/lib/domain/events/funds_withdrawn.dart b/packages/continuum_store_memory/example/lib/domain/events/funds_withdrawn.dart index b1de20d..6f5642c 100644 --- a/packages/continuum_store_memory/example/lib/domain/events/funds_withdrawn.dart +++ b/packages/continuum_store_memory/example/lib/domain/events/funds_withdrawn.dart @@ -1,5 +1,4 @@ import 'package:continuum/continuum.dart'; -import 'package:zooper_flutter_core/zooper_flutter_core.dart'; import '../account.dart'; @@ -28,7 +27,9 @@ class FundsWithdrawn implements ContinuumEvent { factory FundsWithdrawn.fromJson(Map json) { return FundsWithdrawn( - eventId: EventId(json['eventId'] as String), + eventId: EventId.fromJson(json['eventId'] as String), + occurredOn: DateTime.parse(json['occurredOn'] as String), + metadata: Map.from(json['metadata'] as Map), amount: json['amount'] as int, ); } diff --git a/packages/continuum_store_memory/example/lib/domain/events/user_deactivated.dart b/packages/continuum_store_memory/example/lib/domain/events/user_deactivated.dart index 83835c4..4e7c960 100644 --- a/packages/continuum_store_memory/example/lib/domain/events/user_deactivated.dart +++ b/packages/continuum_store_memory/example/lib/domain/events/user_deactivated.dart @@ -1,5 +1,4 @@ import 'package:continuum/continuum.dart'; -import 'package:zooper_flutter_core/zooper_flutter_core.dart'; import '../user.dart'; @@ -30,7 +29,9 @@ class UserDeactivated implements ContinuumEvent { factory UserDeactivated.fromJson(Map json) { return UserDeactivated( - eventId: EventId(json['eventId'] as String), + eventId: EventId.fromJson(json['eventId'] as String), + occurredOn: DateTime.parse(json['occurredOn'] as String), + metadata: Map.from(json['metadata'] as Map), deactivatedAt: DateTime.parse(json['deactivatedAt'] as String), reason: json['reason'] as String?, ); diff --git a/packages/continuum_store_memory/example/lib/domain/events/user_registered.dart b/packages/continuum_store_memory/example/lib/domain/events/user_registered.dart index 5c9f542..6191f93 100644 --- a/packages/continuum_store_memory/example/lib/domain/events/user_registered.dart +++ b/packages/continuum_store_memory/example/lib/domain/events/user_registered.dart @@ -1,5 +1,4 @@ import 'package:continuum/continuum.dart'; -import 'package:zooper_flutter_core/zooper_flutter_core.dart'; import '../user.dart'; @@ -17,7 +16,7 @@ class UserRegistered implements ContinuumEvent { occurredOn = occurredOn ?? DateTime.now(), metadata = Map.unmodifiable(metadata); - final String userId; + final UserId userId; final String email; final String name; @@ -32,12 +31,18 @@ class UserRegistered implements ContinuumEvent { factory UserRegistered.fromJson(Map json) { return UserRegistered( - eventId: EventId(json['eventId'] as String), - userId: json['userId'] as String, + eventId: EventId.fromJson(json['eventId'] as String), + occurredOn: DateTime.parse(json['occurredOn'] as String), + metadata: Map.from(json['metadata'] as Map), + userId: UserId(json['userId'] as String), email: json['email'] as String, name: json['name'] as String, ); } - Map toJson() => {'userId': userId, 'email': email, 'name': name}; + Map toJson() => { + 'userId': userId.value, + 'email': email, + 'name': name, + }; } diff --git a/packages/continuum_store_memory/example/lib/domain/user.dart b/packages/continuum_store_memory/example/lib/domain/user.dart index dcc3340..102b8df 100644 --- a/packages/continuum_store_memory/example/lib/domain/user.dart +++ b/packages/continuum_store_memory/example/lib/domain/user.dart @@ -1,3 +1,4 @@ +import 'package:bounded/bounded.dart'; import 'package:continuum/continuum.dart'; import 'events/email_changed.dart'; @@ -10,28 +11,47 @@ export 'events/user_registered.dart'; part 'user.g.dart'; +/// A strongly-typed user identifier. +final class UserId extends TypedIdentity { + /// Creates a user identifier from a stable string value. + const UserId(super.value); +} + /// A User aggregate demonstrating event sourcing with in-memory persistence. -@Aggregate() -class User with _$UserEventHandlers { - final String id; +class User extends AggregateRoot with _$UserEventHandlers { String email; String name; bool isActive; DateTime? deactivatedAt; - User._({required this.id, required this.email, required this.name, required this.isActive, required this.deactivatedAt}); + User._({ + required UserId id, + required this.email, + required this.name, + required this.isActive, + required this.deactivatedAt, + }) : super(id); static User createFromUserRegistered(UserRegistered event) { - return User._(id: event.userId, email: event.email, name: event.name, isActive: true, deactivatedAt: null); + // WHY: Creation events define the initial state of a new aggregate. + return User._( + id: event.userId, + email: event.email, + name: event.name, + isActive: true, + deactivatedAt: null, + ); } @override void applyEmailChanged(EmailChanged event) { + // WHY: Mutation events update state during replay and live operations. email = event.newEmail; } @override void applyUserDeactivated(UserDeactivated event) { + // WHY: Deactivation is a state transition derived from events. isActive = false; deactivatedAt = event.deactivatedAt; } diff --git a/packages/continuum_store_memory/example/main.dart b/packages/continuum_store_memory/example/main.dart index 7e43809..12f85a2 100644 --- a/packages/continuum_store_memory/example/main.dart +++ b/packages/continuum_store_memory/example/main.dart @@ -31,7 +31,7 @@ void main() async { // Events are stored in memory only - lost when the process exits final store = EventSourcingStore( eventStore: InMemoryEventStore(), - aggregates: $aggregateList, // Auto-generated from @Aggregate classes + aggregates: $aggregateList, // Auto-generated from AggregateRoot classes ); print('Creating a user...'); @@ -42,7 +42,7 @@ void main() async { final user = session.startStream( userId, UserRegistered( - userId: 'user-001', + userId: const UserId('user-001'), email: 'alice@example.com', name: 'Alice', ), diff --git a/packages/continuum_store_memory/example/pubspec.yaml b/packages/continuum_store_memory/example/pubspec.yaml index 65fa0a0..f3fb6c7 100644 --- a/packages/continuum_store_memory/example/pubspec.yaml +++ b/packages/continuum_store_memory/example/pubspec.yaml @@ -1,19 +1,18 @@ name: continuum_store_memory_example description: Example demonstrating the Continuum in-memory event store. -version: 1.0.0 publish_to: none environment: sdk: ^3.10.4 dependencies: + zooper_flutter_core: ^2.0.0 + bounded: ^1.0.0 continuum: path: ../../continuum continuum_store_memory: path: ../ - zooper_flutter_core: ^1.0.3 - dev_dependencies: build_runner: ^2.4.15 lints: ^3.0.0 diff --git a/packages/continuum_store_memory/pubspec.yaml b/packages/continuum_store_memory/pubspec.yaml index 68295f7..b5205a0 100644 --- a/packages/continuum_store_memory/pubspec.yaml +++ b/packages/continuum_store_memory/pubspec.yaml @@ -10,10 +10,9 @@ environment: resolution: workspace dependencies: + zooper_flutter_core: ^2.0.0 continuum: ^4.0.1 - zooper_flutter_core: ^1.0.3 - dev_dependencies: build_runner: ^2.4.0 continuum_generator: ^4.0.1 diff --git a/packages/continuum_store_memory/test/in_memory_event_store_test.dart b/packages/continuum_store_memory/test/in_memory_event_store_test.dart index 64883cd..a10e529 100644 --- a/packages/continuum_store_memory/test/in_memory_event_store_test.dart +++ b/packages/continuum_store_memory/test/in_memory_event_store_test.dart @@ -1,7 +1,6 @@ import 'package:continuum/continuum.dart'; import 'package:continuum_store_memory/continuum_store_memory.dart'; import 'package:test/test.dart'; -import 'package:zooper_flutter_core/zooper_flutter_core.dart'; void main() { group('InMemoryEventStore', () { From 2ab3677dcfdb17b8c1b3bea049f73343148486e8 Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 22 Jan 2026 12:37:10 +0800 Subject: [PATCH 7/9] Test fixes --- .../test/combining_builder_test.dart | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/continuum_generator/test/combining_builder_test.dart b/packages/continuum_generator/test/combining_builder_test.dart index b5d4ca2..2f3158c 100644 --- a/packages/continuum_generator/test/combining_builder_test.dart +++ b/packages/continuum_generator/test/combining_builder_test.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'dart:isolate'; import 'package:build/build.dart'; @@ -11,12 +12,20 @@ void main() { late TestReaderWriter readerWriter; setUpAll(() async { - final uri = await Isolate.packageConfig; - if (uri == null) { - throw StateError('Missing Isolate.packageConfig; cannot run build_test in a pub workspace.'); + // In some CI environments (notably when running tests via workspace tooling), + // `Isolate.packageConfig` can be null. Fall back to locating the + // `.dart_tool/package_config.json` file relative to the working directory. + Uri? packageConfigUri = await Isolate.packageConfig; + packageConfigUri ??= _tryFindPackageConfigUriFromWorkingDirectory(); + + if (packageConfigUri == null) { + throw StateError( + 'Missing package config. `Isolate.packageConfig` was null and no ' + '`.dart_tool/package_config.json` could be found from `${Directory.current.path}`.', + ); } - packageConfig = await loadPackageConfigUri(uri); + packageConfig = await loadPackageConfigUri(packageConfigUri); }); setUp(() async { @@ -173,3 +182,20 @@ class NotAnAggregate {} }); }); } + +Uri? _tryFindPackageConfigUriFromWorkingDirectory() { + Directory currentDirectory = Directory.current; + while (true) { + final Uri candidateUri = currentDirectory.uri.resolve('.dart_tool/package_config.json'); + if (File.fromUri(candidateUri).existsSync()) { + return candidateUri; + } + + final Directory parentDirectory = currentDirectory.parent; + if (parentDirectory.path == currentDirectory.path) { + return null; + } + + currentDirectory = parentDirectory; + } +} From 8c079bda6d85bd65e8f8a93b1cd6f94d3b5a7850 Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 22 Jan 2026 12:48:27 +0800 Subject: [PATCH 8/9] Another fix --- .../generator_abstract_interface_test.dart | 51 +++++++++++++++++-- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/packages/continuum_generator/test/generator_abstract_interface_test.dart b/packages/continuum_generator/test/generator_abstract_interface_test.dart index 61fe6a6..4e3d622 100644 --- a/packages/continuum_generator/test/generator_abstract_interface_test.dart +++ b/packages/continuum_generator/test/generator_abstract_interface_test.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'dart:isolate'; import 'package:build/build.dart'; @@ -11,12 +12,20 @@ void main() { late TestReaderWriter readerWriter; setUpAll(() async { - final uri = await Isolate.packageConfig; - if (uri == null) { - throw StateError('Missing Isolate.packageConfig; cannot run build_test in a pub workspace.'); + // In some CI environments (notably when running tests via workspace tooling), + // `Isolate.packageConfig` can be null. Fall back to locating the + // `.dart_tool/package_config.json` file relative to the working directory. + Uri? packageConfigUri = await Isolate.packageConfig; + packageConfigUri ??= _tryFindPackageConfigUriFromWorkingDirectory(); + + if (packageConfigUri == null) { + throw StateError( + 'Missing package config. `Isolate.packageConfig` was null and no ' + '`.dart_tool/package_config.json` could be found from `${Directory.current.path}`.', + ); } - packageConfig = await loadPackageConfigUri(uri); + packageConfig = await loadPackageConfigUri(packageConfigUri); }); setUp(() async { @@ -43,6 +52,23 @@ final class UserId extends TypedIdentity { const UserId(super.value); } +Uri? _tryFindPackageConfigUriFromWorkingDirectory() { + Directory currentDirectory = Directory.current; + while (true) { + final Uri candidateUri = currentDirectory.uri.resolve('.dart_tool/package_config.json'); + if (File.fromUri(candidateUri).existsSync()) { + return candidateUri; + } + + final Directory parentDirectory = currentDirectory.parent; + if (parentDirectory.path == currentDirectory.path) { + return null; + } + + currentDirectory = parentDirectory; + } +} + abstract class UserBase extends AggregateRoot { UserBase(super.id); } @@ -216,3 +242,20 @@ class AudioFileDeletedEvent implements ContinuumEvent { }); }); } + +Uri? _tryFindPackageConfigUriFromWorkingDirectory() { + Directory currentDirectory = Directory.current; + while (true) { + final Uri candidateUri = currentDirectory.uri.resolve('.dart_tool/package_config.json'); + if (File.fromUri(candidateUri).existsSync()) { + return candidateUri; + } + + final Directory parentDirectory = currentDirectory.parent; + if (parentDirectory.path == currentDirectory.path) { + return null; + } + + currentDirectory = parentDirectory; + } +} From 8decbf4fadb2ffb44d9074f23c1c2ebf3ef53b60 Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 22 Jan 2026 13:09:15 +0800 Subject: [PATCH 9/9] WIP --- CHANGELOG.md | 2 ++ .../continuum_generator/test/combining_builder_test.dart | 5 +++++ .../test/generator_abstract_interface_test.dart | 5 +++++ pubspec.yaml | 2 +- 4 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2be488a..4c2387f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `continuum_missing_apply_handlers` no longer requires `apply(...)` handlers for creation events marked with `@AggregateEvent(creation: true)`. - Fix `ContinuumEvent` contract so core compilation succeeds (restoring required `id`, `occurredOn`, and `metadata` fields). - Fix `ContinuumEvent.metadata` typing to match examples/generator fixtures (`Map`). +- Stabilize `continuum_generator` tests in CI by falling back to `.dart_tool/package_config.json` when `Isolate.packageConfig` is unavailable. +- Run `melos run test` with `--concurrency 1` to reduce workspace test flakiness. ## [4.0.0] - 2026-01-14 diff --git a/packages/continuum_generator/test/combining_builder_test.dart b/packages/continuum_generator/test/combining_builder_test.dart index 2f3158c..42bb53e 100644 --- a/packages/continuum_generator/test/combining_builder_test.dart +++ b/packages/continuum_generator/test/combining_builder_test.dart @@ -183,6 +183,11 @@ class NotAnAggregate {} }); } +/// Attempts to locate the workspace `.dart_tool/package_config.json` file. +/// +/// CI and workspace runners can launch `dart test` in ways where +/// `Isolate.packageConfig` is unavailable. In those cases, build_test still +/// needs a real package config to resolve `package:` imports. Uri? _tryFindPackageConfigUriFromWorkingDirectory() { Directory currentDirectory = Directory.current; while (true) { diff --git a/packages/continuum_generator/test/generator_abstract_interface_test.dart b/packages/continuum_generator/test/generator_abstract_interface_test.dart index 4e3d622..e072ee1 100644 --- a/packages/continuum_generator/test/generator_abstract_interface_test.dart +++ b/packages/continuum_generator/test/generator_abstract_interface_test.dart @@ -52,6 +52,11 @@ final class UserId extends TypedIdentity { const UserId(super.value); } +/// Attempts to locate the workspace `.dart_tool/package_config.json` file. +/// +/// CI and workspace runners can launch `dart test` in ways where +/// `Isolate.packageConfig` is unavailable. In those cases, build_test still +/// needs a real package config to resolve `package:` imports. Uri? _tryFindPackageConfigUriFromWorkingDirectory() { Directory currentDirectory = Directory.current; while (true) { diff --git a/pubspec.yaml b/pubspec.yaml index e3a48c6..db9f502 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -24,7 +24,7 @@ melos: description: Run static analysis in all packages. test: - run: dart run melos exec --fail-fast -- "dart test" + run: dart run melos exec --fail-fast --concurrency 1 -- "dart test" description: Run all package tests. packageFilters: dirExists: test