fix(a11y): resolve serious accessibility issues on search page (GOO-110)
- Add aria-hidden="true" to all decorative inline SVGs (bookmark, view-mode, funnel, checkmark) - Convert save-search popover to proper dialog: role="dialog", aria-modal, focus trap, Escape key, focus return to trigger - Add aria-pressed on list/map/split view-mode toggle buttons - Add aria-expanded + aria-controls on mobile filter toggle button - Add role="status" + aria-label="Đang tải..." on Suspense fallback Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
129
apps/api/src/modules/read-models/README.md
Normal file
129
apps/api/src/modules/read-models/README.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# `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)
|
||||
Reference in New Issue
Block a user