Initial Commit
This commit is contained in:
138
openspec/changes/event-sourcing-completion/proposal.md
Normal file
138
openspec/changes/event-sourcing-completion/proposal.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# Event-sourcing completion
|
||||
|
||||
**Package**: `@suaveplan/event-sourcing`
|
||||
**Status**: PROPOSED
|
||||
**Type**: spec-conformance + feature
|
||||
|
||||
## Why
|
||||
|
||||
The 2026-05-02 cross-monorepo audit confirmed that `@suaveplan/event-sourcing` shipped on genesis with only the inner-loop pieces — `Aggregate`, `aggregate-repository`, `MemoryEventStore`, the `Projection` builder, and a flat `registerUpcaster` / `upcastEvents` pair. The legacy `packages/universal/event-sourcing/src` package on `../suaveplan/` carries six additional capabilities — `EventOutbox`, `DatabaseEventStore`, `DatabaseSnapshotStore`, `EventReplayer` with progress / pause / resume, a `SubscriptionFilter` + `EventBus` pair, a `SnapshotManager` with state reducer + pruning, and a versioned `UpcasterChain` with `detectSchemaVersion` + helper builders. None of those were archived as out-of-scope in the original migration; they were silently dropped.
|
||||
|
||||
Genesis policy (CLAUDE.md §Workflow rule 5) is "preserve the full public surface during rewrites — trimming is OK ONLY when code is duplicated elsewhere in-repo or can be replaced by an existing `@suaveplan/*` library." None of the six dropped capabilities are duplicated elsewhere on genesis, so each one must be ported. This change closes that gap in one coordinated pass so the next consumer (drive-network, the upcoming projection adapters, and any service that needs reliable event publishing) has a coherent surface to build on.
|
||||
|
||||
## What changes
|
||||
|
||||
- ADDED: `EventOutbox` interface — durable transactional outbox; methods `append(events)`, `relay(publisher)`, `pendingCount()`, `failedCount()`, `cleanup(beforeIso)`.
|
||||
- ADDED: `MemoryEventOutbox` class — in-memory implementation of `EventOutbox` for tests and single-process services; preserves insertion order and per-row `attempts` counter.
|
||||
- ADDED: `OutboxPublisher` type alias — `(events: DomainEvent[]) => Promise<void>`.
|
||||
- ADDED: `OutboxRelay` worker class — drains pending records on a configurable interval through an injectable `Clock`; honours `AbortSignal`; emits `outbox.relayed` / `outbox.failed` OTel counters.
|
||||
- ADDED: `DatabaseEventStore<TEvent>` class — `EventStore` adapter backed by a `DatabaseAdapter` from `@suaveplan/db`; optimistic-concurrency check via expected version inside `transaction()`; ledger-style append-only events table with auto-create.
|
||||
- ADDED: `DatabaseSnapshotStore<TState>` class — `SnapshotStoreLike` adapter; latest-snapshot-per-aggregate persistence; configurable `serializer` / `deserializer` so non-JSON state types round-trip.
|
||||
- ADDED: `EventReplayer` class — `replay({ aggregateId | eventType, filter, batchSize, onProgress, signal })`, `pause()`, `resume()`, `cancel()`, `reset()`; `ReplayProgress` with `total` / `processed` / `percentage` / `paused` / `completed`; `ReplayFilter` (eventTypes, fromDate, toDate, aggregateId).
|
||||
- ADDED: `EventBus` class with `SubscriptionFilter` — typed `subscribe(handler, { filter, group })`, `publish(event)`, `publishAll(events)`, round-robin group delivery, `subscriberCount`, `clear()`; subscription returns an `EventSubscription { id, unsubscribe(), active }` handle.
|
||||
- ADDED: `DeadLetterSink` interface + `MemoryDeadLetterSink` — receives events whose handler threw; preserves the original `cause` on `EventSourcingError(EVENT_HANDLER_FAILED)`.
|
||||
- ADDED: `SnapshotManager<TState>` class — automatic snapshot creation at configurable intervals; `load(aggregateId, initialState, reducer)` returns `AggregateLoadResult<TState>` (`state`, `version`, `restoredFromSnapshot`, `eventsReplayed`); pruning to `maxSnapshots` per aggregate; emits `snapshot.created` and `aggregate.loaded` OTel events.
|
||||
- ADDED: `StateReducer<TState>` type alias — `(state: TState, event: DomainEvent) => TState`.
|
||||
- ADDED: `UpcasterChain` ordered registry — `register(upcaster)`, `registerAll(upcasters)`, `upcast(event)`, `upcastAll(events)`, `getUpcasters()`, `getUpcastersForType(type)`, `hasUpcast(event)`, `clear()`. Upcasters are sorted by `fromVersion` so consumers do not depend on registration order.
|
||||
- ADDED: `detectSchemaVersion(event)` — reads `metadata.schemaVersion`, defaults to `1`.
|
||||
- ADDED: `createUpcastedEvent(event, newPayload, targetVersion)` and `createRenamedEvent(event, newType, newPayload, targetVersion)` helpers.
|
||||
- ADDED: `createFieldRenameUpcaster`, `createDefaultFieldUpcaster`, `createRemoveFieldUpcaster` builder functions.
|
||||
- ADDED: `EventSubscriptionOptions`, `EventSubscription`, `SubscriptionFilter`, `OutboxConfig`, `OutboxEntryStatus`, `OutboxEntry`, `DatabaseEventStoreConfig`, `DatabaseSnapshotStoreConfig`, `ReplayConfig`, `ReplayFilter`, `ReplayProgress`, `ProgressCallback`, `ReplayHandler`, `SnapshotManagerConfig`, `AggregateLoadResult`, `VersionedEventMetadata`, `VersionedEvent` types — all colocated with their implementation modules.
|
||||
- MODIFIED: `ES_CODES` registry — adds `OUTBOX_TABLE_INIT_FAILED`, `OUTBOX_PUBLISH_FAILED`, `OUTBOX_RELAY_INTERRUPTED`, `EVENT_INVALID`, `EVENT_HANDLER_FAILED`, `REPLAY_CANCELLED`, `REPLAY_FILTER_INVALID`, `SUBSCRIPTION_INVALID`, `DATABASE_TABLE_INIT_FAILED`, `SNAPSHOT_HANDLER_FAILED`, `UPCASTER_DUPLICATE` codes; the existing `CONCURRENCY_CONFLICT`, `AGGREGATE_NOT_FOUND`, `SNAPSHOT_INVALID`, `UPCASTER_MISSING`, `STREAM_CORRUPT`, `APPEND_FAILED` codes are preserved.
|
||||
- MODIFIED: `index.ts` barrel — re-exports every new symbol; legacy `registerUpcaster` / `clearUpcasters` / `upcastEvents` continue to be exported and now delegate to a module-scoped `UpcasterChain` so both APIs operate over the same registry.
|
||||
- MODIFIED: `package.json` `exports` map — adds subpaths `./outbox/event-outbox`, `./outbox/memory-outbox`, `./outbox/relay`, `./store/database-event-store`, `./store/database-snapshot-store`, `./replay/event-replayer`, `./subscription/event-bus`, `./subscription/dead-letter`, `./snapshot/snapshot-manager`, `./upcasting/chain`.
|
||||
- MODIFIED: `package.json` `dependencies` — adds `@suaveplan/db` (`workspace:^`) for the database-backed adapters.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **SurrealDB / PostgreSQL / SQLite-specific event-store and snapshot-store adapters** — each lands as its own package (`@suaveplan/event-sourcing-surrealdb`, `@suaveplan/event-sourcing-postgres`, `@suaveplan/event-sourcing-sqlite`) so the engine-pluggable matrix mirrors `@suaveplan/crypto` / `@suaveplan/crypto-sodium` / `@suaveplan/crypto-browser`. `DatabaseEventStore` here only needs to satisfy the universal `DatabaseAdapter` contract; per-engine quirks (RANGE locks, `RETURNING` clauses, BLOB serialization) belong with the engine adapter.
|
||||
- **CQRS read-model framework** — `Projection` builder + `EventReplayer` cover the foundation; a full read-model registry with view-versioning, gap detection, and dead-letter rebuild is its own change (`@suaveplan/cqrs`).
|
||||
- **Saga / process-manager pattern** — long-running workflow coordination has dependencies (`@suaveplan/fsm`, `@suaveplan/idempotency`, distributed lock provider) that warrant a separate proposal.
|
||||
- **Distributed event bus** (Kafka / NATS / RabbitMQ adapters) — the `EventBus` class shipped here is in-process publish/subscribe; cross-process distribution is its own change once the wire-format spec is firm.
|
||||
- **`registerUpcaster` legacy free-function deprecation** — kept and delegated; removing it is a breaking change that requires its own deprecation window.
|
||||
- **Re-folding of `openspec/specs/event-sourcing/spec.md`** if a canonical spec file does not yet exist, this change creates it for the cap; full spec folding lands once all event-sourcing changes archive.
|
||||
|
||||
## Source mapping
|
||||
|
||||
This change derives from `../suaveplan/packages/universal/event-sourcing/src/`. Genesis `enterprise/event-sourcing.md` is a generic enterprise template (no per-feature requirements), so source mapping cites the upstream package source directly.
|
||||
|
||||
| Requirement | Source file | Source lines | Notes |
|
||||
|---|---|---|---|
|
||||
| `EventOutbox` interface + `MemoryEventOutbox` class | `packages/universal/event-sourcing/src/outbox/outbox-pattern.ts` | L62-L197 | Class-based upstream is database-coupled; split into interface + memory impl on genesis so the abstraction is testable without `@suaveplan/db` |
|
||||
| `OutboxPublisher` type | `packages/universal/event-sourcing/src/outbox/outbox-pattern.ts` | L15 | Verbatim |
|
||||
| `OutboxConfig`, `OutboxEntryStatus`, `OutboxRow` (renamed `OutboxEntry`) types | `packages/universal/event-sourcing/src/outbox/outbox-pattern.ts` | L18-L42 | Verbatim shape; `tableName`/`autoCreate` only apply to the database-backed variant |
|
||||
| `OutboxRelay` worker | `packages/universal/event-sourcing/src/outbox/outbox-pattern.ts` | L103-L140 | Extracted from upstream `relay()` method into a standalone worker class with injectable `Clock` and `AbortSignal` |
|
||||
| `DatabaseEventStore` class | `packages/universal/event-sourcing/src/store/database-event-store.ts` | L44-L191 | Verbatim; `EventStoreDatabaseAdapter` upstream replaced by `DatabaseAdapter` from `@suaveplan/db` |
|
||||
| `DatabaseEventStoreConfig` | `packages/universal/event-sourcing/src/store/database-event-store.ts` | L21-L28 | Verbatim |
|
||||
| Optimistic-concurrency append | `packages/universal/event-sourcing/src/store/database-event-store.ts` | L56-L104 | Verbatim; emits `EventSourcingError(CONCURRENCY_CONFLICT)` |
|
||||
| `DatabaseSnapshotStore<TState>` class | `packages/universal/event-sourcing/src/store/database-snapshot-store.ts` | L37-L137 | Verbatim; serializer / deserializer hooks added so non-JSON state types round-trip |
|
||||
| `EventReplayer` class with progress / pause / resume / cancel | `packages/universal/event-sourcing/src/replay/event-replayer.ts` | L56-L250 | Verbatim; `AbortSignal` threaded through `replay` / `replayByType` / `replayEvents` |
|
||||
| `ReplayFilter`, `ReplayProgress`, `ReplayConfig`, `ReplayHandler`, `ProgressCallback` types | `packages/universal/event-sourcing/src/replay/event-replayer.ts` | L13-L50 | Verbatim |
|
||||
| `EventBus` class with `SubscriptionFilter` | `packages/universal/event-sourcing/src/subscription/event-subscription.ts` | L93-L200 | Verbatim; subscriptionId generation switched to injectable `IdGenerator` to drop module-scope counter |
|
||||
| `SubscriptionFilter`, `SubscriptionOptions`, `EventSubscription`, `EventHandler` types | `packages/universal/event-sourcing/src/subscription/event-subscription.ts` | L13-L41 | Verbatim |
|
||||
| `matchesFilter` predicate helper | `packages/universal/event-sourcing/src/subscription/event-subscription.ts` | L60-L85 | Verbatim |
|
||||
| `SnapshotManager<TState>` class | `packages/universal/event-sourcing/src/snapshot/snapshot-manager.ts` | L49-L177 | Verbatim; `new Date()` replaced with injectable `Clock` |
|
||||
| `AggregateLoadResult<TState>`, `StateReducer<TState>`, `SnapshotManagerConfig` | `packages/universal/event-sourcing/src/snapshot/snapshot-manager.ts` | L19-L42 | Verbatim |
|
||||
| `UpcasterChain` ordered registry | `packages/universal/event-sourcing/src/versioning/event-upcaster.ts` | L91-L142 | Verbatim |
|
||||
| `detectSchemaVersion`, `createUpcastedEvent`, `createRenamedEvent` | `packages/universal/event-sourcing/src/versioning/event-upcaster.ts` | L42-L82 | Verbatim |
|
||||
| `createFieldRenameUpcaster`, `createDefaultFieldUpcaster` | `packages/universal/event-sourcing/src/versioning/event-upcaster.ts` | L147-L203 | Verbatim |
|
||||
| `VersionedEventMetadata`, `VersionedEvent`, `EventUpcaster` types | `packages/universal/event-sourcing/src/versioning/event-upcaster.ts` | L13-L36 | Verbatim |
|
||||
| `eventSourcingEvents` lifecycle emitter (`event:appended`, `snapshot:created`, `aggregate:loaded`) | `packages/universal/event-sourcing/src/event-sourcing-events.ts` | L14-L30 | Verbatim type map; emitter rewired to OTel counters/spans on genesis to avoid coupling tests to a global emitter |
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
| Requirement | Justification |
|
||||
|---|---|
|
||||
| Replace upstream `EventStoreDatabaseAdapter` with `DatabaseAdapter` from `@suaveplan/db` | `@suaveplan/db` already exists on genesis (`packages/database/db/src/`) and is the canonical adapter contract; introducing a second `EventStoreDatabaseAdapter` would fork the abstraction. |
|
||||
| Inject `Clock` and `IdGenerator` from `@suaveplan/types` everywhere upstream uses `Date.now()` / `new Date()` / module-scope counters | Genesis-wide CLAUDE.md determinism rule: "no `Math.random()` / `Date.now()` at module scope; injectable clock and RNG" (enterprise/event-sourcing.md L40). Applies to outbox `id` generation, snapshot `takenAt`, OTel histograms, subscription IDs. |
|
||||
| `AbortSignal` threaded through `EventReplayer.replay*`, `OutboxRelay.start`, `EventBus.publishAll` | Genesis-wide CLAUDE.md rule: "Backpressure & cancellation — `AbortSignal` everywhere async". Upstream lacks signal support in these surfaces. |
|
||||
| `DeadLetterSink` interface + `MemoryDeadLetterSink` implementation | Upstream `EventBus.publish` swallows handler errors silently when grouped subscriptions throw. Genesis policy (CLAUDE.md §Workflow rule 4: professional honesty + no silent failures) requires a structured dead-letter path with the original `cause` preserved on `EventSourcingError(EVENT_HANDLER_FAILED)`. |
|
||||
| Split `EventOutbox` into interface + `MemoryEventOutbox` + `DatabaseEventStore`-backed wiring | Upstream couples the outbox class to a database adapter. Genesis policy (CLAUDE.md §Workflow rule 3: enterprise-grade modularity) requires the contract to be testable in pure memory before any persistence engine binds. |
|
||||
| Zod schemas for `OutboxConfig`, `ReplayFilter`, `SubscriptionOptions`, `SnapshotManagerConfig`, `DatabaseEventStoreConfig`, `DatabaseSnapshotStoreConfig` | enterprise/event-sourcing.md L37: "Zod schemas for all public types". Upstream validates through TypeScript only. |
|
||||
| New `ES_*` codes for outbox / database / replay / subscription / dead-letter / snapshot / upcaster duplicate failures | Genesis-wide policy (CLAUDE.md §Workflow rule 2) that every thrown error route through `@suaveplan/error`. Upstream throws plain `Error` for several of these paths. |
|
||||
|
||||
## Verification approach
|
||||
|
||||
Each spec delta requirement carries a `**Verified by:**` line pointing at concrete `it(...)` titles. The test files that will exist after this change ships:
|
||||
|
||||
- `packages/events/event-sourcing/src/outbox/event-outbox.test.ts` — interface contract, `MemoryEventOutbox` insertion order + attempts counter
|
||||
- `packages/events/event-sourcing/src/outbox/event-outbox.spec-conformance.test.ts` — Zod-rejected configs emit `OUTBOX_TABLE_INIT_FAILED`; injected `IdGenerator` deterministic; OTel counters
|
||||
- `packages/events/event-sourcing/src/outbox/relay.test.ts` — successful relay marks rows published; failure increments attempts; `AbortSignal` aborts in-flight loops with `OUTBOX_RELAY_INTERRUPTED`
|
||||
- `packages/events/event-sourcing/src/store/database-event-store.test.ts` — append + load round-trip; optimistic-concurrency conflict raises `CONCURRENCY_CONFLICT`; `getEventsByType` honours `limit`
|
||||
- `packages/events/event-sourcing/src/store/database-event-store.spec-conformance.test.ts` — table auto-create gated on `autoCreate: true`; `transaction()` rollback on adapter exception; emits `event:appended` on success
|
||||
- `packages/events/event-sourcing/src/store/database-snapshot-store.test.ts` — save / load latest-per-aggregate; configurable serializer round-trips a non-JSON state; `delete` returns `false` for unknown aggregate
|
||||
- `packages/events/event-sourcing/src/replay/event-replayer.test.ts` — full replay invokes handler in stream order; filter narrows by `eventTypes` / `aggregateId` / `fromDate` / `toDate`; pause / resume / cancel / reset state machine; `onProgress` final tick has `completed: true`
|
||||
- `packages/events/event-sourcing/src/replay/event-replayer.spec-conformance.test.ts` — abort signal mid-batch rejects with `REPLAY_CANCELLED`; bad filter rejected with `REPLAY_FILTER_INVALID`
|
||||
- `packages/events/event-sourcing/src/subscription/event-bus.test.ts` — `subscribe` / `publish` round-trip; filter narrows by `eventTypes` / `aggregateIds` / `aggregateType`; group round-robin distribution; `unsubscribe` removes record
|
||||
- `packages/events/event-sourcing/src/subscription/event-bus.spec-conformance.test.ts` — handler exceptions route to `DeadLetterSink` with `EVENT_HANDLER_FAILED` cause; injected `IdGenerator` produces deterministic subscription IDs; `subscriberCount` reflects `unsubscribe()`
|
||||
- `packages/events/event-sourcing/src/subscription/dead-letter.test.ts` — `MemoryDeadLetterSink` capture; iteration order; `clear()` resets
|
||||
- `packages/events/event-sourcing/src/snapshot/snapshot-manager.test.ts` — `load` rebuilds from snapshot + tail events; auto-create at `interval`; pruning at `maxSnapshots`; `shouldCreateSnapshot` predicate
|
||||
- `packages/events/event-sourcing/src/snapshot/snapshot-manager.spec-conformance.test.ts` — injected `Clock` stamps `takenAt` deterministically; `aggregate:loaded` OTel event carries `restoredFromSnapshot`; reducer exception raises `SNAPSHOT_HANDLER_FAILED`
|
||||
- `packages/events/event-sourcing/src/upcasting/chain.test.ts` — `register` orders by `fromVersion`; `upcast` walks chain v1→v2→v3; `getUpcastersForType` filters; `clear()` empties registry
|
||||
- `packages/events/event-sourcing/src/upcasting/chain.spec-conformance.test.ts` — `detectSchemaVersion` defaults to `1`; duplicate `(eventType, fromVersion)` registration raises `UPCASTER_DUPLICATE`; `createFieldRenameUpcaster` round-trip
|
||||
- `tests/dep-graph.test.ts` (cross-package) — `@suaveplan/event-sourcing` declares `@suaveplan/db` as a runtime dep; the package still has zero browser-only deps
|
||||
|
||||
## Migration impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/event-sourcing` to enumerate. As of `grep -rl "@suaveplan/event-sourcing" packages/ --include="package.json"` (2026-05-02), the only match is the package's own `package.json` — there are no in-repo dependents on genesis yet.
|
||||
|
||||
- (no in-repo dependents) — additive only; existing barrel exports (`Aggregate`, `MemoryEventStore`, `createAggregateRepository`, `createProjection`, `registerUpcaster`, `upcastEvents`) are preserved unchanged. New exports (`EventOutbox`, `DatabaseEventStore`, `DatabaseSnapshotStore`, `EventReplayer`, `EventBus`, `SnapshotManager`, `UpcasterChain`, `detectSchemaVersion`, `createFieldRenameUpcaster`, etc.) are additive.
|
||||
|
||||
The legacy `registerUpcaster` / `clearUpcasters` / `upcastEvents` flat API continues to work and now delegates to a module-scoped `UpcasterChain` so both APIs operate over the same registry. No breaking changes; consumers can adopt the new class API at their own pace.
|
||||
|
||||
For foundation-package safety, a `bunx turbo build typecheck test --filter=...@suaveplan/event-sourcing` sweep runs in §6 of `tasks.md`.
|
||||
|
||||
## Parity Checklist
|
||||
|
||||
| Capability | Upstream | Migrated | Notes |
|
||||
|---|---|---|---|
|
||||
| `Aggregate` base class | yes | yes | Already on genesis |
|
||||
| `aggregate-repository` factory | yes | yes | Already on genesis |
|
||||
| `MemoryEventStore` | yes | yes | Already on genesis |
|
||||
| `DatabaseEventStore` (DB-backed) | yes | yes (this change) | Ported to `@suaveplan/db` adapter |
|
||||
| `Snapshot` type | yes | yes | Already on genesis |
|
||||
| `DatabaseSnapshotStore` | yes | yes (this change) | Adds serializer hooks |
|
||||
| `SnapshotManager<TState>` | yes | yes (this change) | `new Date()` → injectable `Clock` |
|
||||
| `Projection` builder | yes | yes | Already on genesis |
|
||||
| `EventBus` + `SubscriptionFilter` | yes | yes (this change) | Adds dead-letter sink |
|
||||
| `EventReplayer` (progress / pause / resume) | yes | yes (this change) | Adds `AbortSignal` |
|
||||
| `EventOutbox` + `OutboxRelay` | yes | yes (this change) | Split interface / memory / DB |
|
||||
| `UpcasterChain` ordered registry | yes | yes (this change) | Replaces flat `registerUpcaster` |
|
||||
| `detectSchemaVersion`, `createUpcastedEvent`, `createRenamedEvent` | yes | yes (this change) | Verbatim |
|
||||
| `createFieldRenameUpcaster`, `createDefaultFieldUpcaster` | yes | yes (this change) | Adds `createRemoveFieldUpcaster` |
|
||||
| `eventSourcingEvents` lifecycle emitter | yes | yes (this change) | Rewired to OTel; back-compat type map preserved |
|
||||
| Flat `registerUpcaster` / `upcastEvents` | yes | yes | Already on genesis; now delegates to `UpcasterChain` |
|
||||
|
||||
No upstream capabilities are dropped.
|
||||
@@ -0,0 +1,257 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: event-sourcing (event-sourcing-completion)
|
||||
|
||||
**Package**: `@suaveplan/event-sourcing`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Transactional outbox pattern
|
||||
|
||||
The package SHALL expose an `EventOutbox` interface and a `MemoryEventOutbox` reference implementation that lets callers persist domain events alongside aggregate writes and forward them to external consumers in a separate, retry-safe step. The interface SHALL define `append(events)`, `relay(publisher)`, `pendingCount()`, `failedCount()`, and `cleanup(beforeIso)`. Outbox entries SHALL preserve insertion order, carry a per-row `attempts` counter, and transition through `pending` → `published` (success) or `pending` → `failed` (retry exhaustion).
|
||||
|
||||
#### Scenario: append preserves insertion order
|
||||
|
||||
- **WHEN** three events are appended via `outbox.append([e1, e2, e3])`
|
||||
- **THEN** a subsequent `relay(publisher)` invokes `publisher` with the events in the same `[e1, e2, e3]` order
|
||||
|
||||
#### Scenario: relay marks rows published on success
|
||||
|
||||
- **WHEN** `relay` is called and the supplied `publisher` resolves
|
||||
- **THEN** every drained row transitions from `pending` to `published` AND `relay()` returns the count of relayed events AND `pendingCount()` decreases by that count
|
||||
|
||||
#### Scenario: relay increments attempts on publisher failure
|
||||
|
||||
- **WHEN** `relay` is called and the publisher rejects
|
||||
- **THEN** every drained row's `attempts` counter increments AND rows below the retry cap remain `pending` AND rows at the retry cap transition to `failed` AND `relay()` returns `0`
|
||||
|
||||
#### Scenario: invalid OutboxConfig raises OUTBOX_TABLE_INIT_FAILED
|
||||
|
||||
- **WHEN** an outbox is constructed with a non-Zod-conformant `OutboxConfig` (e.g. negative `batchSize`)
|
||||
- **THEN** the constructor throws `EventSourcingError` with code `ES.OUTBOX_TABLE_INIT_FAILED` and the offending field in `context.field`
|
||||
|
||||
**Verified by:** `packages/events/event-sourcing/src/outbox/event-outbox.test.ts::"preserves insertion order across append and relay"` AND `packages/events/event-sourcing/src/outbox/event-outbox.test.ts::"marks rows published on successful relay"` AND `packages/events/event-sourcing/src/outbox/event-outbox.test.ts::"increments attempts on publisher failure"` AND `packages/events/event-sourcing/src/outbox/event-outbox.spec-conformance.test.ts::"raises OUTBOX_TABLE_INIT_FAILED on invalid config"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Outbox relay worker with cancellation
|
||||
|
||||
The package SHALL expose an `OutboxRelay` worker class that drains pending outbox rows on a configurable interval (default 1000 ms) using an injectable `Clock` from `@suaveplan/types`. The worker SHALL accept an `AbortSignal`; when the signal aborts mid-drain, the worker SHALL reject pending publishes with `EventSourcingError(ES.OUTBOX_RELAY_INTERRUPTED)` and stop scheduling further iterations.
|
||||
|
||||
#### Scenario: AbortSignal terminates the relay loop
|
||||
|
||||
- **WHEN** an `OutboxRelay` is started with an `AbortController`'s signal and `controller.abort()` fires mid-drain
|
||||
- **THEN** the active `relay()` call rejects with `EventSourcingError(ES.OUTBOX_RELAY_INTERRUPTED)` AND no further `Clock.schedule` calls are made
|
||||
|
||||
#### Scenario: injected Clock controls poll interval
|
||||
|
||||
- **WHEN** an `OutboxRelay` is constructed with a fake `Clock` advancing 100 ms per tick and `pollIntervalMs: 500`
|
||||
- **THEN** the relay loop fires once every five fake ticks AND OTel counter `outbox.relayed` increments by `events.length` per successful drain
|
||||
|
||||
**Verified by:** `packages/events/event-sourcing/src/outbox/relay.test.ts::"rejects pending publish with OUTBOX_RELAY_INTERRUPTED when signal aborts"` AND `packages/events/event-sourcing/src/outbox/relay.test.ts::"polls on the injected Clock interval"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Database-backed event store with optimistic concurrency
|
||||
|
||||
The package SHALL expose a `DatabaseEventStore<TEvent extends DomainEvent>` class that satisfies the `EventStore` interface and persists events through a `DatabaseAdapter` from `@suaveplan/db`. Every `append(aggregateId, events, expectedVersion)` SHALL run inside `adapter.transaction()`; if the current max version does not match `expectedVersion`, the store SHALL raise `EventSourcingError(ES.CONCURRENCY_CONFLICT)` and roll back the transaction. The events table SHALL be auto-created when `autoCreate: true` (default).
|
||||
|
||||
#### Scenario: optimistic-concurrency conflict raises CONCURRENCY_CONFLICT
|
||||
|
||||
- **WHEN** `append("agg-1", events, expectedVersion: 0)` is called and the stored stream is already at version 2
|
||||
- **THEN** the call rejects with `EventSourcingError(ES.CONCURRENCY_CONFLICT)` AND `context` contains `{ aggregateId: "agg-1", expectedVersion: 0, currentVersion: 2 }` AND no rows are inserted
|
||||
|
||||
#### Scenario: append + load round-trip
|
||||
|
||||
- **WHEN** events are appended successfully and `getEvents("agg-1")` is called
|
||||
- **THEN** the returned array preserves insertion order AND each event's `payload` and `metadata` round-trip through the configured serializer
|
||||
|
||||
#### Scenario: getEventsByType honours limit
|
||||
|
||||
- **WHEN** `getEventsByType("OrderPlaced", limit: 5)` is called
|
||||
- **THEN** at most 5 events are returned AND they are ordered by `created_at` ascending
|
||||
|
||||
#### Scenario: table auto-create gated on autoCreate flag
|
||||
|
||||
- **WHEN** the store is constructed with `autoCreate: false` and the underlying table does not exist
|
||||
- **THEN** `append` rejects with `EventSourcingError(ES.DATABASE_TABLE_INIT_FAILED)` rather than silently creating the table
|
||||
|
||||
**Verified by:** `packages/events/event-sourcing/src/store/database-event-store.test.ts::"raises CONCURRENCY_CONFLICT when expectedVersion does not match"` AND `packages/events/event-sourcing/src/store/database-event-store.test.ts::"appends and loads in stream order"` AND `packages/events/event-sourcing/src/store/database-event-store.test.ts::"limits getEventsByType results"` AND `packages/events/event-sourcing/src/store/database-event-store.spec-conformance.test.ts::"gates table creation on autoCreate flag"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Database-backed snapshot store
|
||||
|
||||
The package SHALL expose a `DatabaseSnapshotStore<TState>` class that persists the latest snapshot per aggregate via a `DatabaseAdapter`. The store SHALL accept optional `serializer` / `deserializer` config so non-JSON state types round-trip. The `shouldSnapshot(eventsSinceLastSnapshot)` method SHALL return `true` when `eventsSinceLastSnapshot >= interval`.
|
||||
|
||||
#### Scenario: latest snapshot per aggregate
|
||||
|
||||
- **WHEN** two snapshots are saved for `aggregateId: "agg-1"` (versions 5 and 10)
|
||||
- **THEN** `load("agg-1")` returns the version-10 snapshot AND a third call to `save` updates rather than inserts
|
||||
|
||||
#### Scenario: serializer round-trips non-JSON state
|
||||
|
||||
- **WHEN** the store is configured with a `MessagePack` serializer / deserializer pair and a `Map`-typed state is saved
|
||||
- **THEN** `load(aggregateId)` returns a `Map` with the same entries
|
||||
|
||||
#### Scenario: delete returns false for unknown aggregate
|
||||
|
||||
- **WHEN** `delete("never-saved")` is called
|
||||
- **THEN** the result is `false` AND no SQL DELETE is dispatched
|
||||
|
||||
**Verified by:** `packages/events/event-sourcing/src/store/database-snapshot-store.test.ts::"returns the latest snapshot per aggregate"` AND `packages/events/event-sourcing/src/store/database-snapshot-store.test.ts::"round-trips non-JSON state via injected serializer"` AND `packages/events/event-sourcing/src/store/database-snapshot-store.test.ts::"returns false when deleting an unknown aggregate"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Event replayer with progress / pause / resume / cancel
|
||||
|
||||
The package SHALL expose an `EventReplayer` class that replays events from an `EventStore` through a handler with batch processing, progress callbacks, pause / resume / cancel control, and `AbortSignal` support. `ReplayFilter` SHALL narrow by `eventTypes`, `aggregateId`, `fromDate`, `toDate`. `ReplayProgress` SHALL expose `total`, `processed`, `percentage`, `paused`, `completed`. The terminal `onProgress` tick SHALL carry `completed: true`.
|
||||
|
||||
#### Scenario: replay invokes handler in stream order
|
||||
|
||||
- **WHEN** `replay("agg-1", handler)` is called against a stream of 100 events
|
||||
- **THEN** `handler` is invoked exactly 100 times in stream order AND the final `onProgress` tick has `processed: 100, percentage: 100, completed: true`
|
||||
|
||||
#### Scenario: filter narrows by eventTypes
|
||||
|
||||
- **WHEN** `replay("agg-1", handler, { eventTypes: ["OrderPlaced"] })` is called against a stream that mixes `OrderPlaced` and `OrderShipped`
|
||||
- **THEN** `handler` is invoked only for `OrderPlaced` events AND `total` reflects the filtered count
|
||||
|
||||
#### Scenario: pause halts processing until resume
|
||||
|
||||
- **WHEN** `pause()` is called mid-replay and `resume()` is called 50 ms later
|
||||
- **THEN** the handler stops being invoked between `pause` and `resume` AND a `paused: true` progress tick is emitted at the pause boundary
|
||||
|
||||
#### Scenario: AbortSignal mid-batch rejects with REPLAY_CANCELLED
|
||||
|
||||
- **WHEN** `replay("agg-1", handler, undefined, signal)` is in flight and the signal aborts
|
||||
- **THEN** the call rejects with `EventSourcingError(ES.REPLAY_CANCELLED)` AND in-flight handler invocations are not awaited past the abort point
|
||||
|
||||
#### Scenario: invalid filter rejected up front
|
||||
|
||||
- **WHEN** `replay("agg-1", handler, { fromDate: "not-an-iso-date" })` is called
|
||||
- **THEN** the call rejects with `EventSourcingError(ES.REPLAY_FILTER_INVALID)` and `context.fromDate` is the offending value
|
||||
|
||||
**Verified by:** `packages/events/event-sourcing/src/replay/event-replayer.test.ts::"invokes handler in stream order across batches"` AND `packages/events/event-sourcing/src/replay/event-replayer.test.ts::"narrows replayed events by eventTypes filter"` AND `packages/events/event-sourcing/src/replay/event-replayer.test.ts::"halts processing between pause and resume"` AND `packages/events/event-sourcing/src/replay/event-replayer.spec-conformance.test.ts::"rejects with REPLAY_CANCELLED when signal aborts mid-batch"` AND `packages/events/event-sourcing/src/replay/event-replayer.spec-conformance.test.ts::"rejects invalid filter with REPLAY_FILTER_INVALID"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Event bus with subscription filter and dead-letter sink
|
||||
|
||||
The package SHALL expose an `EventBus` class implementing typed publish/subscribe over `DomainEvent`. `subscribe(handler, options?)` SHALL accept a `SubscriptionFilter` (`eventTypes`, `aggregateIds`, `aggregateType`) and an optional `group` string; multiple subscribers in the same group receive events in round-robin order, while ungrouped subscribers all receive every matching event. Handler exceptions SHALL be routed to a `DeadLetterSink` with the original `cause` preserved on `EventSourcingError(ES.EVENT_HANDLER_FAILED)`. Subscription IDs SHALL come from an injectable `IdGenerator` so tests can pin them.
|
||||
|
||||
#### Scenario: subscribe and publish round-trip
|
||||
|
||||
- **WHEN** a handler subscribes without a filter and an event is published
|
||||
- **THEN** the handler is invoked exactly once with the published event
|
||||
|
||||
#### Scenario: filter narrows by aggregateType
|
||||
|
||||
- **WHEN** a subscriber's filter sets `aggregateType: "Order"` and an `Account` event is published
|
||||
- **THEN** the handler is not invoked AND `subscriberCount` is unchanged
|
||||
|
||||
#### Scenario: group round-robin distribution
|
||||
|
||||
- **WHEN** three subscribers join group `"workers"` and four events are published
|
||||
- **THEN** subscriber A handles events 1 and 4, subscriber B handles event 2, subscriber C handles event 3
|
||||
|
||||
#### Scenario: handler exception routes to DeadLetterSink
|
||||
|
||||
- **WHEN** a subscribed handler throws synchronously and a `MemoryDeadLetterSink` is configured
|
||||
- **THEN** the sink captures the failed event AND the captured entry's `cause.code === ES.EVENT_HANDLER_FAILED` AND `cause.context.subscriptionId` matches the failing subscription
|
||||
|
||||
#### Scenario: unsubscribe removes record
|
||||
|
||||
- **WHEN** `subscription.unsubscribe()` is called
|
||||
- **THEN** `subscription.active === false` AND `subscriberCount` decreases by 1 AND subsequent publishes do not invoke the handler
|
||||
|
||||
**Verified by:** `packages/events/event-sourcing/src/subscription/event-bus.test.ts::"invokes handler exactly once on publish without filter"` AND `packages/events/event-sourcing/src/subscription/event-bus.test.ts::"narrows delivery by aggregateType filter"` AND `packages/events/event-sourcing/src/subscription/event-bus.test.ts::"distributes events round-robin within a group"` AND `packages/events/event-sourcing/src/subscription/event-bus.spec-conformance.test.ts::"routes handler exceptions to DeadLetterSink with EVENT_HANDLER_FAILED cause"` AND `packages/events/event-sourcing/src/subscription/event-bus.test.ts::"removes record when unsubscribe is called"` AND `packages/events/event-sourcing/src/subscription/dead-letter.test.ts::"captures failed events in insertion order"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Snapshot manager with reducer and pruning
|
||||
|
||||
The package SHALL expose a `SnapshotManager<TState>` class that automates snapshot creation at a configurable `interval` and prunes per-aggregate history to `maxSnapshots`. `load(aggregateId, initialState, reducer)` SHALL fetch the latest snapshot, replay tail events through `reducer`, and return an `AggregateLoadResult<TState>` carrying `state`, `version`, `restoredFromSnapshot`, `eventsReplayed`. `takenAt` timestamps SHALL come from an injectable `Clock`.
|
||||
|
||||
#### Scenario: load rebuilds state from snapshot plus tail events
|
||||
|
||||
- **WHEN** a snapshot exists at version 10 and 3 newer events are in the store
|
||||
- **THEN** `load(...)` invokes the reducer 3 times (not 13) AND `result.eventsReplayed === 3` AND `result.restoredFromSnapshot === true`
|
||||
|
||||
#### Scenario: auto-create snapshot at configured interval
|
||||
|
||||
- **WHEN** `load` replays >= `interval` events past the last snapshot
|
||||
- **THEN** `SnapshotManager` calls `snapshotStore.save` with the rebuilt state AND a `snapshot.created` lifecycle event fires AND `getSnapshotHistory(aggregateId)` includes the new entry
|
||||
|
||||
#### Scenario: pruning enforces maxSnapshots
|
||||
|
||||
- **WHEN** snapshots `maxSnapshots + 2` exist for one aggregate
|
||||
- **THEN** the oldest 2 are pruned from the in-memory history AND `getSnapshotHistory(aggregateId).length === maxSnapshots`
|
||||
|
||||
#### Scenario: reducer exception raises SNAPSHOT_HANDLER_FAILED
|
||||
|
||||
- **WHEN** the supplied `reducer` throws while replaying tail events
|
||||
- **THEN** `load` rejects with `EventSourcingError(ES.SNAPSHOT_HANDLER_FAILED)` AND `cause` is the original exception
|
||||
|
||||
#### Scenario: injected Clock stamps takenAt deterministically
|
||||
|
||||
- **WHEN** a fake `Clock` returning a fixed instant is injected and a snapshot is created
|
||||
- **THEN** the saved snapshot's `takenAt` equals the fake instant
|
||||
|
||||
**Verified by:** `packages/events/event-sourcing/src/snapshot/snapshot-manager.test.ts::"rebuilds state from snapshot plus tail events"` AND `packages/events/event-sourcing/src/snapshot/snapshot-manager.test.ts::"auto-creates snapshot at the configured interval"` AND `packages/events/event-sourcing/src/snapshot/snapshot-manager.test.ts::"prunes per-aggregate history to maxSnapshots"` AND `packages/events/event-sourcing/src/snapshot/snapshot-manager.spec-conformance.test.ts::"raises SNAPSHOT_HANDLER_FAILED when reducer throws"` AND `packages/events/event-sourcing/src/snapshot/snapshot-manager.spec-conformance.test.ts::"stamps takenAt from the injected Clock"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Versioned upcaster chain with schema-version detection
|
||||
|
||||
The package SHALL expose an `UpcasterChain` class that holds an ordered registry of `EventUpcaster` entries and applies them in `fromVersion` order during `upcast(event)`. `detectSchemaVersion(event)` SHALL read `metadata.schemaVersion`, defaulting to `1` when absent. `createUpcastedEvent` and `createRenamedEvent` SHALL stamp `schemaVersion: targetVersion` on the returned event's metadata. `createFieldRenameUpcaster`, `createDefaultFieldUpcaster`, and `createRemoveFieldUpcaster` SHALL produce upcasters that handle a single `(eventType, fromVersion)` step. Duplicate `(eventType, fromVersion)` registrations SHALL raise `EventSourcingError(ES.UPCASTER_DUPLICATE)`.
|
||||
|
||||
#### Scenario: chain walks v1 to v3 in order
|
||||
|
||||
- **WHEN** upcasters for `(OrderPlaced, 1→2)` and `(OrderPlaced, 2→3)` are registered out of order
|
||||
- **THEN** `chain.upcast(v1Event)` returns a v3 event AND the upcasters fire in `fromVersion` order regardless of registration order
|
||||
|
||||
#### Scenario: detectSchemaVersion defaults to 1
|
||||
|
||||
- **WHEN** an event has no `metadata.schemaVersion`
|
||||
- **THEN** `detectSchemaVersion(event)` returns `1`
|
||||
|
||||
#### Scenario: getUpcastersForType filters by eventType
|
||||
|
||||
- **WHEN** the chain holds upcasters for `OrderPlaced` and `OrderShipped`
|
||||
- **THEN** `getUpcastersForType("OrderPlaced")` returns only the `OrderPlaced` entries in `fromVersion` order
|
||||
|
||||
#### Scenario: createFieldRenameUpcaster round-trip
|
||||
|
||||
- **WHEN** `createFieldRenameUpcaster("OrderPlaced", 1, 2, { customer: "customerId" })` is registered and applied to a v1 payload `{ customer: "c-1", total: 100 }`
|
||||
- **THEN** the upcasted payload is `{ customerId: "c-1", total: 100 }` AND `metadata.schemaVersion === 2`
|
||||
|
||||
#### Scenario: duplicate registration rejected
|
||||
|
||||
- **WHEN** two upcasters are registered with the same `(eventType, fromVersion)` pair
|
||||
- **THEN** the second `register()` call raises `EventSourcingError(ES.UPCASTER_DUPLICATE)` and the chain remains at one entry for that key
|
||||
|
||||
**Verified by:** `packages/events/event-sourcing/src/upcasting/chain.test.ts::"walks the chain in fromVersion order regardless of registration order"` AND `packages/events/event-sourcing/src/upcasting/chain.spec-conformance.test.ts::"detectSchemaVersion defaults to 1 when metadata is absent"` AND `packages/events/event-sourcing/src/upcasting/chain.test.ts::"filters upcasters by eventType"` AND `packages/events/event-sourcing/src/upcasting/chain.spec-conformance.test.ts::"createFieldRenameUpcaster renames the field and stamps schemaVersion"` AND `packages/events/event-sourcing/src/upcasting/chain.spec-conformance.test.ts::"raises UPCASTER_DUPLICATE on collision"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Backwards-compatible flat upcaster API delegates to UpcasterChain
|
||||
|
||||
The package SHALL preserve the existing flat `registerUpcaster(options)`, `clearUpcasters()`, and `upcastEvents(events)` exports. These functions SHALL delegate to a single module-scoped `UpcasterChain` instance so consumers calling either API observe the same registry.
|
||||
|
||||
#### Scenario: flat register and class chain see the same upcasters
|
||||
|
||||
- **WHEN** `registerUpcaster({ eventType: "OrderPlaced", fromVersion: 1, toVersion: 2, upcast })` is called
|
||||
- **THEN** the module-scoped `UpcasterChain.getUpcasters()` includes that entry AND a subsequent `upcastEvents([v1Event])` invocation produces the same v2 result as `chain.upcastAll([v1Event])`
|
||||
|
||||
#### Scenario: clearUpcasters resets the shared chain
|
||||
|
||||
- **WHEN** `clearUpcasters()` is called
|
||||
- **THEN** both `upcastEvents([anyEvent])` and `chain.getUpcasters()` reflect an empty registry
|
||||
|
||||
**Verified by:** `packages/events/event-sourcing/src/upcasting/chain.test.ts::"flat registerUpcaster delegates to the module chain"` AND `packages/events/event-sourcing/src/upcasting/chain.test.ts::"clearUpcasters empties the shared chain"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
117
openspec/changes/event-sourcing-completion/tasks.md
Normal file
117
openspec/changes/event-sourcing-completion/tasks.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# Tasks — Event-sourcing completion
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change. The seven capabilities closed here were silently dropped during the original genesis migration (audit dated 2026-05-02). Do not retroactively tick boxes for surface that already shipped.
|
||||
|
||||
## 0. Pre-flight (non-negotiable)
|
||||
|
||||
- [ ] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
|
||||
- [ ] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
|
||||
- [ ] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [ ] 1.1 Read `../suaveplan/packages/universal/event-sourcing/src/{outbox,store,replay,subscription,snapshot,versioning,event-sourcing-events}.ts` end-to-end
|
||||
- [ ] 1.2 Read `openspec/specs/event-sourcing/spec.md` (if it exists; create the cap dir if not)
|
||||
- [ ] 1.3 Read `openspec/AGENTS.md` Sections 0a (parity), 13 (spec-conformance), 14 (source mapping), 14a (dependents)
|
||||
- [ ] 1.4 Draft `proposal.md` covering all seven gaps (`EventOutbox`, `DatabaseEventStore`, `DatabaseSnapshotStore`, `EventReplayer`, `EventBus + SubscriptionFilter`, `SnapshotManager`, `UpcasterChain`)
|
||||
- [ ] 1.5 Fill `## Source mapping` in `proposal.md` with upstream line ranges for every requirement; every "no source line" requirement justified
|
||||
- [ ] 1.6 Draft spec delta at `specs/event-sourcing/spec.md`
|
||||
- [ ] 1.7 Every requirement in the spec delta carries a `**Verified by:**` line naming concrete `it(...)` titles
|
||||
- [ ] 1.8 `openspec change validate event-sourcing-completion` exits with no errors
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [ ] 2.1 `EventOutbox` interface + `MemoryEventOutbox` class — port from upstream `outbox/outbox-pattern.ts` into `packages/events/event-sourcing/src/outbox/event-outbox.ts` (interface) and `packages/events/event-sourcing/src/outbox/memory-outbox.ts` (impl)
|
||||
- [ ] 2.2 `OutboxRelay` worker — extract relay loop into a standalone class with injectable `Clock` and `AbortSignal`; new file `packages/events/event-sourcing/src/outbox/relay.ts`
|
||||
- [ ] 2.3 `DatabaseEventStore<TEvent>` — port from upstream `store/database-event-store.ts` into `packages/events/event-sourcing/src/store/database-event-store.ts`; replace `EventStoreDatabaseAdapter` with `DatabaseAdapter` from `@suaveplan/db`
|
||||
- [ ] 2.4 `DatabaseSnapshotStore<TState>` — port from upstream `store/database-snapshot-store.ts` into `packages/events/event-sourcing/src/store/database-snapshot-store.ts`; add `serializer` / `deserializer` config hooks
|
||||
- [ ] 2.5 `EventReplayer` — port from upstream `replay/event-replayer.ts` into `packages/events/event-sourcing/src/replay/event-replayer.ts`; thread `AbortSignal` through `replay`, `replayByType`, `replayEvents`
|
||||
- [ ] 2.6 `EventBus` + `SubscriptionFilter` — port from upstream `subscription/event-subscription.ts` into `packages/events/event-sourcing/src/subscription/event-bus.ts`; replace module-scope counter with injectable `IdGenerator`
|
||||
- [ ] 2.7 `DeadLetterSink` interface + `MemoryDeadLetterSink` — new file `packages/events/event-sourcing/src/subscription/dead-letter.ts`; capture handler exceptions during `EventBus.publish`
|
||||
- [ ] 2.8 `SnapshotManager<TState>` — port from upstream `snapshot/snapshot-manager.ts` into `packages/events/event-sourcing/src/snapshot/snapshot-manager.ts`; replace `new Date()` with injectable `Clock`
|
||||
- [ ] 2.9 `UpcasterChain` ordered registry — port from upstream `versioning/event-upcaster.ts` into `packages/events/event-sourcing/src/upcasting/chain.ts`
|
||||
- [ ] 2.10 `detectSchemaVersion`, `createUpcastedEvent`, `createRenamedEvent` helpers — co-locate next to `UpcasterChain`
|
||||
- [ ] 2.11 `createFieldRenameUpcaster`, `createDefaultFieldUpcaster`, `createRemoveFieldUpcaster` builders — co-locate next to `UpcasterChain`
|
||||
- [ ] 2.12 Wire legacy `registerUpcaster` / `clearUpcasters` / `upcastEvents` to delegate to a module-scoped `UpcasterChain` so both APIs share one registry
|
||||
- [ ] 2.13 Add `ES_*` codes (`OUTBOX_TABLE_INIT_FAILED`, `OUTBOX_PUBLISH_FAILED`, `OUTBOX_RELAY_INTERRUPTED`, `EVENT_INVALID`, `EVENT_HANDLER_FAILED`, `REPLAY_CANCELLED`, `REPLAY_FILTER_INVALID`, `SUBSCRIPTION_INVALID`, `DATABASE_TABLE_INIT_FAILED`, `SNAPSHOT_HANDLER_FAILED`, `UPCASTER_DUPLICATE`) to `errors/event-sourcing-error.ts`
|
||||
- [ ] 2.14 Wire OTel counters / spans on every public entrypoint: `outbox.relayed`, `outbox.failed`, `outbox.pending_size`, `database.events_appended`, `database.snapshots_saved`, `replay.events_processed`, `replay.duration_ms`, `subscription.published`, `subscription.dead_lettered`, `snapshot.created`, `snapshot.pruned`, `upcaster.applied`, `upcaster.skipped`
|
||||
- [ ] 2.15 Wire Zod schemas for `OutboxConfig`, `DatabaseEventStoreConfig`, `DatabaseSnapshotStoreConfig`, `ReplayFilter`, `ReplayConfig`, `SubscriptionOptions`, `SubscriptionFilter`, `SnapshotManagerConfig`, `EventUpcaster` to `schemas/event-sourcing.schemas.ts`
|
||||
- [ ] 2.16 Update `index.ts` barrel — re-export every new symbol; no `export *` (one explicit subpath per module)
|
||||
- [ ] 2.17 Update `package.json` `exports` map — add subpaths `./outbox/event-outbox`, `./outbox/memory-outbox`, `./outbox/relay`, `./store/database-event-store`, `./store/database-snapshot-store`, `./replay/event-replayer`, `./subscription/event-bus`, `./subscription/dead-letter`, `./snapshot/snapshot-manager`, `./upcasting/chain`
|
||||
- [ ] 2.18 Update `package.json` `dependencies` — add `@suaveplan/db` (`workspace:^`)
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [ ] 3.1 `packages/events/event-sourcing/src/outbox/event-outbox.test.ts` — interface contract; `MemoryEventOutbox` insertion order; per-row attempts counter; `pendingCount` / `failedCount` / `cleanup`
|
||||
- [ ] 3.2 `packages/events/event-sourcing/src/outbox/event-outbox.spec-conformance.test.ts` — Zod-rejected configs emit `OUTBOX_TABLE_INIT_FAILED`; injected `IdGenerator` deterministic; OTel counter `outbox.pending_size`
|
||||
- [ ] 3.3 `packages/events/event-sourcing/src/outbox/relay.test.ts` — successful relay marks rows published; failure increments attempts; `AbortSignal` aborts in-flight loops with `OUTBOX_RELAY_INTERRUPTED`; injected `Clock` deterministic
|
||||
- [ ] 3.4 `packages/events/event-sourcing/src/store/database-event-store.test.ts` — append + load round-trip; optimistic-concurrency conflict raises `CONCURRENCY_CONFLICT`; `getEventsByType` honours `limit`; `count()` returns total
|
||||
- [ ] 3.5 `packages/events/event-sourcing/src/store/database-event-store.spec-conformance.test.ts` — table auto-create gated on `autoCreate: true`; `transaction()` rollback on adapter exception; emits `event:appended` lifecycle event
|
||||
- [ ] 3.6 `packages/events/event-sourcing/src/store/database-snapshot-store.test.ts` — save / load latest-per-aggregate; configurable serializer round-trips a non-JSON state; `delete` returns `false` for unknown aggregate; `getInterval` returns config
|
||||
- [ ] 3.7 `packages/events/event-sourcing/src/replay/event-replayer.test.ts` — full replay invokes handler in stream order; filter narrows by `eventTypes` / `aggregateId` / `fromDate` / `toDate`; pause / resume / cancel / reset state machine; `onProgress` final tick has `completed: true`; batch size honoured
|
||||
- [ ] 3.8 `packages/events/event-sourcing/src/replay/event-replayer.spec-conformance.test.ts` — abort signal mid-batch rejects with `REPLAY_CANCELLED`; bad filter rejected with `REPLAY_FILTER_INVALID`; OTel histogram `replay.duration_ms`
|
||||
- [ ] 3.9 `packages/events/event-sourcing/src/subscription/event-bus.test.ts` — `subscribe` / `publish` round-trip; filter narrows by `eventTypes` / `aggregateIds` / `aggregateType`; group round-robin distribution; `unsubscribe` removes record; `subscriberCount` reflects active subscriptions
|
||||
- [ ] 3.10 `packages/events/event-sourcing/src/subscription/event-bus.spec-conformance.test.ts` — handler exceptions route to `DeadLetterSink` with `EVENT_HANDLER_FAILED` cause; injected `IdGenerator` produces deterministic subscription IDs; `clear()` resets `groupCounters`
|
||||
- [ ] 3.11 `packages/events/event-sourcing/src/subscription/dead-letter.test.ts` — `MemoryDeadLetterSink` capture; iteration order; `clear()` resets; `getEntries()` snapshot is immutable
|
||||
- [ ] 3.12 `packages/events/event-sourcing/src/snapshot/snapshot-manager.test.ts` — `load` rebuilds from snapshot + tail events; auto-create at `interval`; pruning at `maxSnapshots`; `shouldCreateSnapshot` predicate; `getSnapshotHistory` per aggregate
|
||||
- [ ] 3.13 `packages/events/event-sourcing/src/snapshot/snapshot-manager.spec-conformance.test.ts` — injected `Clock` stamps `takenAt` deterministically; `aggregate:loaded` lifecycle event carries `restoredFromSnapshot`; reducer exception raises `SNAPSHOT_HANDLER_FAILED`
|
||||
- [ ] 3.14 `packages/events/event-sourcing/src/upcasting/chain.test.ts` — `register` orders by `fromVersion`; `upcast` walks chain v1→v2→v3; `getUpcastersForType` filters; `clear()` empties registry; `hasUpcast` predicate
|
||||
- [ ] 3.15 `packages/events/event-sourcing/src/upcasting/chain.spec-conformance.test.ts` — `detectSchemaVersion` defaults to `1`; duplicate `(eventType, fromVersion)` registration raises `UPCASTER_DUPLICATE`; `createFieldRenameUpcaster` / `createDefaultFieldUpcaster` / `createRemoveFieldUpcaster` round-trip
|
||||
- [ ] 3.16 Cross-package `tests/dep-graph.test.ts` — `@suaveplan/event-sourcing` declares `@suaveplan/db` as a runtime dep; the package still has zero browser-only deps
|
||||
- [ ] 3.17 Every `it(...)` carries `{ timeout: 5000 }` (or 30000 for genuinely-async DB-adapter integration)
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [ ] 4.1 Sibling `.md` for every new implementation `.ts` (≥200 words, basic + advanced examples, full API reference) — `outbox/event-outbox.md`, `outbox/memory-outbox.md`, `outbox/relay.md`, `store/database-event-store.md`, `store/database-snapshot-store.md`, `replay/event-replayer.md`, `subscription/event-bus.md`, `subscription/dead-letter.md`, `snapshot/snapshot-manager.md`, `upcasting/chain.md`
|
||||
- [ ] 4.2 README links every co-located `.md` under `## Modules`
|
||||
- [ ] 4.3 Update `CLAUDE.md` `event-sourcing` package listing — remove "only Aggregate / aggregate-repository / MemoryEventStore / Projection / flat upcaster" wording; describe the full restored surface
|
||||
- [ ] 4.4 Refresh `STATUS.md` "Completed on genesis" entry for `event-sourcing` to reflect parity
|
||||
|
||||
## 5. Pre-archive verification (the 5-gate triple)
|
||||
|
||||
- [ ] 5.1 `bun run check` — Biome 0 errors / 0 warnings / 0 info across the monorepo
|
||||
- [ ] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/event-sourcing` — green
|
||||
- [ ] 5.3 `bunx turbo test:coverage --filter=@suaveplan/event-sourcing` — 100/100/100/100
|
||||
- [ ] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/event-sourcing` — exit 0, no `error` findings
|
||||
- [ ] 5.5 `bunx turbo build --filter=@suaveplan/event-sourcing` — green
|
||||
- [ ] 5.6 Banned-pattern scan zero output: `grep -rn "@deprecated\|TODO\|FIXME\|ts-ignore\|ts-expect-error\|\.skip\|\.only" packages/events/event-sourcing/src/ --include="*.ts"`
|
||||
- [ ] 5.7 Missing `.md` scan zero output (per AGENTS.md §13 step 5)
|
||||
- [ ] 5.8 README check `test -f packages/events/event-sourcing/README.md`
|
||||
- [ ] 5.9 Bare-version check zero output (per AGENTS.md §13 step 7)
|
||||
- [ ] 5.10 Subpath count matches feature module count (per AGENTS.md §13 step 8)
|
||||
|
||||
## 6. Pre-archive 7-row checklist (AGENTS.md Section 13)
|
||||
|
||||
- [ ] 6.1 Generics threaded; `expectTypeOf` assertions on every literal-type claim (especially `DatabaseEventStore<TEvent>`, `DatabaseSnapshotStore<TState>`, `SnapshotManager<TState>`)
|
||||
- [ ] 6.2 Zod schemas wired at every declared boundary (`OutboxConfig`, `DatabaseEventStoreConfig`, `DatabaseSnapshotStoreConfig`, `ReplayFilter`, `ReplayConfig`, `SubscriptionOptions`, `SubscriptionFilter`, `SnapshotManagerConfig`, `EventUpcaster`)
|
||||
- [ ] 6.3 Every error code added in §2.13 thrown by at least one production code path; no orphans
|
||||
- [ ] 6.4 Strict-mode behaviors tested both on and off (e.g. `autoCreate: true` vs `false`; `paused` vs running replay; `cancel()` vs `reset()`)
|
||||
- [ ] 6.5 OTel counters / histograms emitted from production paths and asserted with a real in-memory `MeterProvider`
|
||||
- [ ] 6.6 Lifecycle dedup tested where claimed (`SnapshotManager.shouldCreateSnapshot` idempotent on identical inputs; `EventBus.unsubscribe()` on already-unsubscribed record is a no-op)
|
||||
- [ ] 6.7 No retroactive tick — every box above reflects work done in this change
|
||||
|
||||
## 7. Archive
|
||||
|
||||
- [ ] 7.1 Fold spec delta into `openspec/specs/event-sourcing/spec.md` (creating the cap file if missing)
|
||||
- [ ] 7.2 Move `openspec/changes/event-sourcing-completion/` → `openspec/archive/<yyyy-mm-dd>-event-sourcing-completion/`
|
||||
- [ ] 7.3 Refresh `MANIFEST.md` (dep set changed: `@suaveplan/event-sourcing` now depends on `@suaveplan/db`)
|
||||
- [ ] 7.4 Update `STATUS.md` to mark the seven dropped capabilities as restored
|
||||
|
||||
## Audit Note (2026-05-02)
|
||||
|
||||
Audit verdict: **DEFERRED — 0/68 tasks evidenced in code**.
|
||||
|
||||
Evidence reviewed:
|
||||
|
||||
- `packages/events/event-sourcing/src/` contains `core/{aggregate,event-store,aggregate-repository}/`, `projection/builder/`, `upcasting/upcaster/`, `errors/`, `schemas/`, `types/`, `constants/`. No `outbox/`, no `replay/`, no `subscription/`, no `snapshot/`, no `store/database-*`.
|
||||
- `package.json` does not declare `@suaveplan/db` as a runtime dep (the `DatabaseEventStore` and `DatabaseSnapshotStore` need it).
|
||||
- No new `ES_*` error codes (`OUTBOX_*`, `REPLAY_*`, `SUBSCRIPTION_*`, `SNAPSHOT_HANDLER_FAILED`, `UPCASTER_DUPLICATE`, `EVENT_HANDLER_FAILED`, etc.) in `errors/event-sourcing-error.ts`.
|
||||
- Existing `upcasting/upcaster/` is a single class — no `UpcasterChain` ordered registry, no `createFieldRenameUpcaster` / `createDefaultFieldUpcaster` / `createRemoveFieldUpcaster` builders.
|
||||
|
||||
Missing pieces:
|
||||
|
||||
1. Outbox subsystem (`EventOutbox` interface, `MemoryEventOutbox`, `OutboxRelay` worker with injectable `Clock` + `AbortSignal`).
|
||||
2. `DatabaseEventStore<TEvent>` + `DatabaseSnapshotStore<TState>` (requires adding `@suaveplan/db` dep).
|
||||
3. `EventReplayer` (with pause/resume/cancel/reset state machine) + `EventBus` with `SubscriptionFilter` + `MemoryDeadLetterSink` + `SnapshotManager<TState>` + `UpcasterChain` ordered registry.
|
||||
|
||||
Decision: leave change open. Seven dropped capabilities, ten new subpath exports, eleven new error codes, dep graph change — all blocked behind real implementation work.
|
||||
Reference in New Issue
Block a user