# `read-models` module

Phase 0 skeleton for the CQRS read-model expansion described in
[RFC-003](../../../docs/adr/0003-cqrs-read-models.md) (the ADR itself
lands with [GOO-193](/GOO/issues/GOO-193); until then RFC-003 lives on
[GOO-94](/GOO/issues/GOO-94)).

## Layout

```
read-models/
  domain/
    projection-context.ts            # ProjectionContext, ProjectableEvent
    projection-offset-store.ts       # IProjectionOffsetStore port + DI symbol
    read-repository.ts               # IReadRepository convention marker
  application/
    projectors/
      projector.base.ts              # Projector<E> base class
    repositories/                    # I<Name>ReadRepository interfaces (Phase 2/3)
  infrastructure/
    refresh/                         # mat-view refresh cron (Phase 1)
    reconciliation/                  # nightly drift checker (Phase 2+)
  testing/
    in-memory-projection-offset-store.ts  # unit-test harness
  read-models.module.ts
  index.ts
```

This mirrors the layout RFC-003 §5 specifies; intentionally **no
`presentation/`** because read models are infrastructure for other
modules' query handlers, not their own HTTP surface.

## The projector contract

Every read-model projector extends `Projector<E extends DomainEvent>`
and implements:

```ts
@EventsHandler(MyDomainEvent)
export class MyProjector extends Projector<MyDomainEvent> {
  readonly handlerName = 'my-projector.v1';

  protected async apply(event: MyDomainEvent, ctx: ProjectionContext) {
    // write to your read model
  }
}

// glue (one of):
@EventsHandler(MyDomainEvent)
export class MyProjectorGlue implements IEventHandler<MyDomainEvent> {
  constructor(private readonly projector: MyProjector) {}
  handle(event: MyDomainEvent) { return this.projector.dispatch(event); }
}
```

Subclasses MUST:

- set `handlerName` to a **stable string** (rename = re-projection — be deliberate);
- implement `apply(event, ctx)` and treat `ctx.eventId` as the unit of idempotency.

Subclasses MUST NOT:

- call `apply` directly — always go through `dispatch(event)`;
- write to write-model tables — read models are read-only from the API
  surface, only mutated by their owning projector or refresh job;
- implement their own deduplication — the base class already does it via
  `IProjectionOffsetStore`.

## The offset / idempotency contract

RFC-003 §0 mandates `(eventId, handlerName)` idempotency:

> The `(eventId, handler)` offset table is non-negotiable. Land it in
> Phase 0 with a unit-test harness so every Phase 2/3 projector inherits it.

This module ships the **port** (`IProjectionOffsetStore`,
`PROJECTION_OFFSET_STORE`) and an in-memory implementation for tests.
The Prisma-backed implementation — including the
`projection_offset(event_id, handler_name, applied_at, payload_hash)`
migration and the transactional wrapper — lands with
[GOO-187](/GOO/issues/GOO-187).

The base class enforces the contract by calling `recordIfAbsent` BEFORE
`apply`. Re-deliveries observe `applied: false` and are skipped. The
offset row is intentionally **not rolled back on `apply` failure** in
Phase 0 — this is the conservative choice (RFC-003 §7) and is healed by
the nightly reconciliation job that lands in Phase 2.

`eventId` is currently derived from
`${aggregateId}:${occurredAt.getTime()}:${eventName}` because the
existing `DomainEvent` interface (`apps/api/src/modules/shared/domain/domain-event.ts`)
does not yet carry a stable id. Override `deriveEventId` on your
projector if your event type provides one. The id contract itself is
finalised in [GOO-187](/GOO/issues/GOO-187); Phase 2/3 projectors should
not bake assumptions about its format.

## The repository convention

For each read model:

1. Define `I<Name>ReadRepository` (extending `IReadRepository`) under
   `application/repositories/`.
2. Export a paired injection symbol `<NAME>_READ_REPOSITORY`.
3. Implement `Prisma<Name>ReadRepository` under
   `infrastructure/repositories/` and bind it in `ReadModelsModule`.
4. Re-export the symbol from the module's `index.ts` so query handlers in
   other modules can `@Inject(LISTING_CARD_READ_REPOSITORY)` without
   reaching into the read-models module's internals.

Read repositories are READ-ONLY from the perspective of the rest of the
API. The only writers are the projector that owns the read model (Option
C) or the materialized-view refresh job (Option B).

## What Phase 0 is NOT

- No `projection_offset` migration — owned by [GOO-187](/GOO/issues/GOO-187).
- No projectors registered.
- No materialized views or refresh job — Phase 1.
- No reconciliation job — Phase 2.
- No `X-Data-Freshness-Seconds` helper — separate Phase 0 ticket.
- No kill-switch / chaos test — separate Phase 0 ticket.

The skeleton exists so the next batch of PRs is purely additive.

## Coordination

- Parent: [GOO-94](/GOO/issues/GOO-94)
- Sibling (offset table + idempotency harness): [GOO-187](/GOO/issues/GOO-187)
- ADR (write-up): [GOO-193](/GOO/issues/GOO-193)
